feat(cms): recover drafts and failed imports with verified UI workflows
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 28 KiB |
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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)));
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { AnchorHTMLAttributes } from "react";
|
||||
export default function FixtureLink({
|
||||
prefetch: _prefetch,
|
||||
...props
|
||||
}: AnchorHTMLAttributes<HTMLAnchorElement> & { prefetch?: boolean }) {
|
||||
return <a {...props} />;
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,6 +1,7 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
testIgnore: "**/ui/**",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 1,
|
||||
|
||||
@@ -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) },
|
||||
},
|
||||
});
|
||||
@@ -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]: {}
|
||||
@@ -3,6 +3,7 @@ allowBuilds:
|
||||
sharp: true
|
||||
"@parcel/watcher": true
|
||||
"@swc/core": true
|
||||
msw: false
|
||||
|
||||
minimumReleaseAge: 1440
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ export default async function CreateEventPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EventForm eventTypes={eventTypes} />
|
||||
<EventForm userId={String(session.user.id)} eventTypes={eventTypes} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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,7 +137,11 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<form
|
||||
onChangeCapture={recovery.changed}
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
>
|
||||
<fieldset disabled={isPending} className="contents">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
@@ -111,6 +149,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
</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>
|
||||
@@ -126,7 +165,9 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
<Label htmlFor="typeId">{t("formEventType")}</Label>
|
||||
<Select
|
||||
value={String(form.watch("typeId"))}
|
||||
onValueChange={(v) => form.setValue("typeId", Number(v))}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("typeId", Number(v), { shouldDirty: true })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("formSelectType")} />
|
||||
@@ -198,7 +239,9 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
<Select
|
||||
value={form.watch("status")}
|
||||
onValueChange={(v) =>
|
||||
form.setValue("status", v as CreateEventInput["status"])
|
||||
form.setValue("status", v as CreateEventInput["status"], {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
@@ -275,6 +318,7 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) {
|
||||
</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();
|
||||
});
|
||||
@@ -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,
|
||||
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",
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}` : ""}` : ""}`,
|
||||
useEffect(
|
||||
() => () => {
|
||||
client.clear();
|
||||
},
|
||||
[client],
|
||||
);
|
||||
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("");
|
||||
useEffect(() => {
|
||||
if (!query.data || unauthorized) return;
|
||||
let changed = false;
|
||||
for (const job of data.jobs as ImportJob[])
|
||||
for (const job of query.data.jobs) {
|
||||
if (
|
||||
(job.state === "completed" ||
|
||||
job.state === "interrupted" ||
|
||||
job.state === "cancelled") &&
|
||||
["completed", "interrupted", "cancelled"].includes(job.state) &&
|
||||
!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);
|
||||
}
|
||||
}, [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.",
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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 ────────────
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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}`;
|
||||