diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index ce0cda90..f0809c21 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -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 diff --git a/e2e/ui/README.md b/e2e/ui/README.md new file mode 100644 index 00000000..c8aa8051 --- /dev/null +++ b/e2e/ui/README.md @@ -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__//`. 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. diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png new file mode 100644 index 00000000..18455262 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-form.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png new file mode 100644 index 00000000..bd5fac9b Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/article-preview.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png new file mode 100644 index 00000000..2ce354f0 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/settings-switches.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png new file mode 100644 index 00000000..951772c4 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/sync-history.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png new file mode 100644 index 00000000..0143a4cf Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-saved-views.png differ diff --git a/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png new file mode 100644 index 00000000..dbe09286 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/desktop/workflows.spec.ts/ticket-table.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png new file mode 100644 index 00000000..ea28e6d2 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-form.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png new file mode 100644 index 00000000..4cb66cd8 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/article-preview.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png new file mode 100644 index 00000000..20761e17 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/settings-switches.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png new file mode 100644 index 00000000..af2dcf71 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/sync-history.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png new file mode 100644 index 00000000..96b93af5 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-saved-views.png differ diff --git a/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png new file mode 100644 index 00000000..c803f758 Binary files /dev/null and b/e2e/ui/__screenshots__/win32/mobile/workflows.spec.ts/ticket-table.png differ diff --git a/e2e/ui/attachment-recovery.spec.ts b/e2e/ui/attachment-recovery.spec.ts new file mode 100644 index 00000000..a52bcadd --- /dev/null +++ b/e2e/ui/attachment-recovery.spec.ts @@ -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); + }); diff --git a/e2e/ui/draft-recovery.spec.ts b/e2e/ui/draft-recovery.spec.ts new file mode 100644 index 00000000..90784b07 --- /dev/null +++ b/e2e/ui/draft-recovery.spec.ts @@ -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(); +}); diff --git a/e2e/ui/fixture-server.mjs b/e2e/ui/fixture-server.mjs new file mode 100644 index 00000000..714f61a8 --- /dev/null +++ b/e2e/ui/fixture-server.mjs @@ -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 = + 'UI verification fixture
'; +const cover = + ''; +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))); diff --git a/e2e/ui/fixtures/attachment-harness.tsx b/e2e/ui/fixtures/attachment-harness.tsx new file mode 100644 index 00000000..a8cfcb21 --- /dev/null +++ b/e2e/ui/fixtures/attachment-harness.tsx @@ -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 ( + <> + { + setResumed((value) => value + 1); + }} + /> + {resumed} + + ); +} diff --git a/e2e/ui/fixtures/entry.tsx b/e2e/ui/fixtures/entry.tsx new file mode 100644 index 00000000..d4ea3c6f --- /dev/null +++ b/e2e/ui/fixtures/entry.tsx @@ -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 => + fixtureAction("article", Object.fromEntries(form)); + +function ArticleFixture() { + const [visible, setVisible] = useState(true); + return ( + <> + {route.includes("recovery") && ( + + )} + {visible && ( + Join the upcoming event and explore the hotel together.

", + status: "draft", + }} + /> + )} + + ); +} + +createRoot(root).render( + +
+

+ {route.includes("settings") + ? "CMS settings" + : route.includes("tickets") + ? "Support desk" + : route.includes("articles") + ? "News editor" + : "Furniture synchronization"} +

+ {route.includes("attachment-harness") ? ( + + ) : route.includes("events/recovery") ? ( + + ) : route.includes("jobs-harness") ? ( + + ) : route.includes("settings") ? ( + + ) : route.includes("tickets") ? ( + + ) : route.includes("articles") ? ( + + ) : ( + + )} +
+ +
, +); diff --git a/e2e/ui/fixtures/furniture-jobs-harness.tsx b/e2e/ui/fixtures/furniture-jobs-harness.tsx new file mode 100644 index 00000000..97e77e1a --- /dev/null +++ b/e2e/ui/fixtures/furniture-jobs-harness.tsx @@ -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 ( +
+ + + + {history.page} + {completions} + {String(history.loading)} + + {history.jobs.map((job) => `${job.id}:${job.state}`).join(",")} + +
+ ); +} diff --git a/e2e/ui/fixtures/navigation.ts b/e2e/ui/fixtures/navigation.ts new file mode 100644 index 00000000..35aa6db2 --- /dev/null +++ b/e2e/ui/fixtures/navigation.ts @@ -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); diff --git a/e2e/ui/fixtures/next-link.tsx b/e2e/ui/fixtures/next-link.tsx new file mode 100644 index 00000000..ff9028d8 --- /dev/null +++ b/e2e/ui/fixtures/next-link.tsx @@ -0,0 +1,7 @@ +import type { AnchorHTMLAttributes } from "react"; +export default function FixtureLink({ + prefetch: _prefetch, + ...props +}: AnchorHTMLAttributes & { prefetch?: boolean }) { + return ; +} diff --git a/e2e/ui/fixtures/server-actions.ts b/e2e/ui/fixtures/server-actions.ts new file mode 100644 index 00000000..85eb71ec --- /dev/null +++ b/e2e/ui/fixtures/server-actions.ts @@ -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); diff --git a/e2e/ui/furniture-jobs.spec.ts b/e2e/ui/furniture-jobs.spec.ts new file mode 100644 index 00000000..492605e6 --- /dev/null +++ b/e2e/ui/furniture-jobs.spec.ts @@ -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); +}); diff --git a/e2e/ui/workflows.spec.ts b/e2e/ui/workflows.spec.ts new file mode 100644 index 00000000..e6224645 --- /dev/null +++ b/e2e/ui/workflows.spec.ts @@ -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"); +}); diff --git a/package.json b/package.json index 9dadbc83..38d190b6 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/playwright.config.ts b/playwright.config.ts index edded504..18fe3bc4 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,6 +1,7 @@ import { defineConfig, devices } from "@playwright/test"; export default defineConfig({ testDir: "./e2e", + testIgnore: "**/ui/**", fullyParallel: false, workers: 1, retries: 1, diff --git a/playwright.ui.config.ts b/playwright.ui.config.ts new file mode 100644 index 00000000..8567868a --- /dev/null +++ b/playwright.ui.config.ts @@ -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) }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75c22e90..8afa8694 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: '@hookform/resolvers': specifier: 5.9.1 version: 5.9.1(react-hook-form@7.87.0(react@19.2.8))(zod@4.5.4) + '@tanstack/react-query': + specifier: 5.102.8 + version: 5.102.8(react@19.2.8) '@tanstack/react-virtual': specifier: 3.14.10 version: 3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -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(playwright-core@1.62.1) '@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/node@26.4.1)(typescript@7.0.2) 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/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + version: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) packages: @@ -208,6 +220,11 @@ packages: nodemailer: optional: true + '@axe-core/playwright@4.13.0': + resolution: {integrity: sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==} + peerDependencies: + playwright-core: '>= 1.0.0' + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -993,6 +1010,41 @@ packages: cpu: [x64] os: [win32] + '@inquirer/ansi@2.0.8': + resolution: {integrity: sha512-WpQM+Ti6Z40EFwwt+uL2p4UabT+W179zHp6HhLVOzfbwnVn05IPO/eXIZXGNqcT1jbQ15SujNLzQ39k4QPPxBQ==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/confirm@6.3.2': + 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/core@12.0.3': + 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/figures@2.0.9': + resolution: {integrity: sha512-EAWgUTGQ/Umgga51dE3B2PUHbufuXarDfg86uVgoSgNHNNQnyFKcOrQLWVqYMghuSyHh8+2HUH0Js9cTC1WAdg==} + engines: {node: '>=23.5.0 || ^22.13.0 || ^20.17.0'} + + '@inquirer/type@4.1.1': + 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/commands@2.0.0': resolution: {integrity: sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==} @@ -1012,6 +1064,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mswjs/interceptors@0.41.9': + resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} + engines: {node: '>=18'} + '@next/env@16.3.4': 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/deferred-promise@2.2.0': + resolution: {integrity: sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==} + + '@open-draft/deferred-promise@3.0.0': + resolution: {integrity: sha512-XW375UK8/9SqUVNVa6M0yEy8+iTi4QN5VZ7aZuRFQmy76LRwI9wy5F4YIBU6T+eTe2/DNDo8tqu8RHlwLHM6RA==} + + '@open-draft/logger@0.3.0': + resolution: {integrity: sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==} + + '@open-draft/until@2.1.0': + resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} + '@otplib/core@13.5.0': resolution: {integrity: sha512-2rURdkYkb3BDhMs3j/oCCPTve1ybJ6ruLfLfSe1ZSPV+y6RFTbLfAuT0m0ZCnps88ogkIq9t/+Li/kg5RofQwQ==} @@ -1656,6 +1724,14 @@ packages: peerDependencies: tailwindcss: '>=3.0.0 || >=4.0.0 || insiders' + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} + + '@tanstack/react-query@5.102.8': + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} + peerDependencies: + react: ^18 || ^19 + '@tanstack/react-virtual@3.14.10': resolution: {integrity: sha512-SRyoUbdFMRHuYXMijV5H4ZarQWpXkj3iANq8OFre+pybeVap8ZJjZ3Nz9bVjx4d8PfobVUQUdKyyyHYk3E+djw==} peerDependencies: @@ -1692,6 +1768,12 @@ packages: '@types/react@19.2.18': resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + '@types/set-cookie-parser@2.4.10': + resolution: {integrity: sha512-GGmQVGpQWUe5qglJozEjZV/5dyxbOOZ0LHe/lqyWssB88Y4svNfst0uqBVscdDeIKl5Jy5+aPSvy7mI9tYRguw==} + + '@types/statuses@2.0.6': + resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1846,6 +1928,14 @@ packages: '@vitest/spy@5.0.0': resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + aria-hidden@1.2.6: 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'} + axe-core@4.13.0: + resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==} + engines: {node: '>=4'} + baseline-browser-mapping@2.11.21: resolution: {integrity: sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==} engines: {node: '>=6.0.0'} @@ -1886,9 +1980,17 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-width@4.1.0: + resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} + engines: {node: '>= 12'} + client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + clsx@2.1.1: 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 + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1910,6 +2019,10 @@ packages: resolution: {integrity: sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==} engines: {node: '>=18'} + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -2058,6 +2171,9 @@ packages: sqlite3: optional: true + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} @@ -2082,6 +2198,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} @@ -2098,6 +2218,15 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2135,6 +2264,10 @@ packages: generate-function@2.3.1: resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} @@ -2145,9 +2278,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphql@16.14.2: + resolution: {integrity: sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==} + engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} + hash-wasm@4.12.0: resolution: {integrity: sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==} + headers-polyfill@5.0.1: + resolution: {integrity: sha512-1TJ6Fih/b8h5TIcv+1+Hw0PDQWJTKDKzFZzcKOiW1wJza3XoAQlkCuXLbymPYB8+ZQyw8mHvdw560e8zVFIWyA==} + help-me@5.0.0: resolution: {integrity: sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==} @@ -2182,10 +2322,17 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-node-process@1.2.0: + resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -2445,10 +2592,24 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + msw@2.15.0: + resolution: {integrity: sha512-2wQAmKkQKxRuXvYJxVhPGG0wZNBQyD06oJvxqw90XqLvptdqxdlHrFUfEteKkpaNORX3Xzc+HtEl/q0nfmN2wQ==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + typescript: '>= 4.8.x' + peerDependenciesMeta: + typescript: + optional: true + music-metadata@11.15.0: resolution: {integrity: sha512-TN+kO1/oOc8UzDW5N3vSDncBcv9WyNnQQe/NFMcFWq6G1+zVeYUkGvkYpQ/S8wb8vKtUD7i78dIaY0cv+cmPRA==} engines: {node: '>=18'} + mute-stream@3.0.0: + resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} + engines: {node: ^20.17.0 || >=22.9.0} + mysql2@3.24.3: resolution: {integrity: sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==} engines: {node: '>= 8.0'} @@ -2534,12 +2695,18 @@ packages: otplib@13.5.0: resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==} + outvariant@1.4.3: + resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} parse5@8.0.1: resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-to-regexp@6.3.0: + resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2672,6 +2839,10 @@ packages: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + require-from-string@2.0.2: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} @@ -2691,6 +2862,9 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rettime@0.11.11: + resolution: {integrity: sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ==} + rolldown@1.2.7: resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2724,6 +2898,9 @@ packages: server-only@0.0.1: resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-cookie-parser@3.1.2: + resolution: {integrity: sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} @@ -2739,6 +2916,10 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -2780,12 +2961,27 @@ packages: standardwebhooks@1.0.0: resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + strict-event-emitter@0.5.1: + resolution: {integrity: sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -2810,6 +3006,10 @@ packages: symbol-tree@3.2.4: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tailwind-merge@3.6.0: resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==} @@ -2870,6 +3070,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@5.9.0: + resolution: {integrity: sha512-yANm3Jr3GiJ1qgJlxGAVxTOIcEOk1rhQHamlXtnrCK7EHP4HeM9OGxtMg/W7HFdrVzw/ZWJKGVIJusVH85sLtw==} + engines: {node: '>=20'} + typescript@7.0.2: 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'} + until-async@3.0.2: + resolution: {integrity: sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==} + use-callback-ref@1.3.3: resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} engines: {node: '>=10'} @@ -3031,6 +3238,10 @@ packages: win-guid@0.2.1: resolution: {integrity: sha512-gEIQU4mkgl2OPeoNrWflcJFJ3Ae2BPd4eCsHHA/XikslkIVms/nHhvnvzIZV7VLmBvtFlDOzLt9rrZT+n6D67A==} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -3041,11 +3252,23 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + zod@4.5.4: resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} @@ -3076,6 +3299,11 @@ snapshots: preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) + '@axe-core/playwright@4.13.0(playwright-core@1.62.1)': + dependencies: + axe-core: 4.13.0 + playwright-core: 1.62.1 + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -3536,6 +3764,33 @@ snapshots: '@img/sharp-win32-x64@0.35.4': optional: true + '@inquirer/ansi@2.0.8': {} + + '@inquirer/confirm@6.3.2(@types/node@26.4.1)': + dependencies: + '@inquirer/core': 12.0.3(@types/node@26.4.1) + '@inquirer/type': 4.1.1(@types/node@26.4.1) + optionalDependencies: + '@types/node': 26.4.1 + + '@inquirer/core@12.0.3(@types/node@26.4.1)': + dependencies: + '@inquirer/ansi': 2.0.8 + '@inquirer/figures': 2.0.9 + '@inquirer/type': 4.1.1(@types/node@26.4.1) + 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/figures@2.0.9': {} + + '@inquirer/type@4.1.1(@types/node@26.4.1)': + optionalDependencies: + '@types/node': 26.4.1 + '@ioredis/commands@2.0.0': {} '@jridgewell/gen-mapping@0.3.13': @@ -3557,6 +3812,15 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.6.0 + '@mswjs/interceptors@0.41.9': + 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/env@16.3.4': {} '@next/swc-darwin-arm64@16.3.4': @@ -3585,6 +3849,17 @@ snapshots: '@noble/hashes@2.4.0': {} + '@open-draft/deferred-promise@2.2.0': {} + + '@open-draft/deferred-promise@3.0.0': {} + + '@open-draft/logger@0.3.0': + dependencies: + is-node-process: 1.2.0 + outvariant: 1.4.3 + + '@open-draft/until@2.1.0': {} + '@otplib/core@13.5.0': {} '@otplib/hotp@13.5.0': @@ -4014,6 +4289,13 @@ snapshots: postcss-selector-parser: 6.0.10 tailwindcss: 4.3.3 + '@tanstack/query-core@5.102.8': {} + + '@tanstack/react-query@5.102.8(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.102.8 + react: 19.2.8 + '@tanstack/react-virtual@3.14.10(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@tanstack/virtual-core': 3.17.8 @@ -4052,6 +4334,12 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/set-cookie-parser@2.4.10': + dependencies: + '@types/node': 26.4.1 + + '@types/statuses@2.0.6': {} + '@types/trusted-types@2.0.7': 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/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + vitest: 5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) '@vitest/istanbul-lib-coverage@1.0.1': {} @@ -4133,17 +4421,24 @@ snapshots: dependencies: '@vitest/istanbul-lib-coverage': 1.0.1 - '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': + '@vitest/mocker@5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0))': 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/node@26.4.1)(typescript@7.0.2) vite: 8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0) '@vitest/spy@5.0.0': {} + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + aria-hidden@1.2.6: dependencies: tslib: 2.8.1 @@ -4160,6 +4455,8 @@ snapshots: aws-ssl-profiles@1.1.2: {} + axe-core@4.13.0: {} + baseline-browser-mapping@2.11.21: {} bidi-js@1.1.0: @@ -4176,8 +4473,16 @@ snapshots: dependencies: clsx: 2.1.1 + cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + clsx@2.1.1: {} cluster-key-slot@1.1.1: {} @@ -4194,10 +4499,18 @@ snapshots: - '@types/react' - '@types/react-dom' + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + colorette@2.0.20: {} content-type@2.1.0: {} + cookie@1.1.1: {} + core-util-is@1.0.3: {} croner@10.0.1: {} @@ -4247,6 +4560,8 @@ snapshots: optionalDependencies: mysql2: 3.24.3(@types/node@26.4.1) + emoji-regex@8.0.0: {} + end-of-stream@1.4.5: dependencies: once: 1.4.0 @@ -4318,6 +4633,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escalade@3.2.0: {} + estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 @@ -4330,6 +4647,16 @@ snapshots: fast-sha256@1.3.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 @@ -4362,6 +4689,8 @@ snapshots: dependencies: is-property: 1.0.2 + get-caller-file@2.0.5: {} + get-nonce@1.0.1: {} get-tsconfig@4.14.3: @@ -4370,8 +4699,15 @@ snapshots: graceful-fs@4.2.11: {} + graphql@16.14.2: {} + hash-wasm@4.12.0: {} + headers-polyfill@5.0.1: + dependencies: + '@types/set-cookie-parser': 2.4.10 + set-cookie-parser: 3.1.2 + help-me@5.0.0: {} html-encoding-sniffer@6.0.0(@noble/hashes@2.4.0): @@ -4412,10 +4748,14 @@ snapshots: is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-node-process@1.2.0: {} + is-potential-custom-element-name@1.0.1: {} is-property@1.0.2: {} @@ -4627,6 +4967,31 @@ snapshots: ms@2.1.3: {} + msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2): + dependencies: + '@inquirer/confirm': 6.3.2(@types/node@26.4.1) + '@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' + music-metadata@11.15.0: dependencies: '@borewit/text-codec': 0.2.2 @@ -4642,6 +5007,8 @@ snapshots: transitivePeerDependencies: - supports-color + mute-stream@3.0.0: {} + mysql2@3.24.3(@types/node@26.4.1): dependencies: '@types/node': 26.4.1 @@ -4735,12 +5102,16 @@ snapshots: '@otplib/totp': 13.5.0 '@otplib/uri': 13.5.0 + outvariant@1.4.3: {} + pako@1.0.11: {} parse5@8.0.1: dependencies: entities: 8.0.0 + path-to-regexp@6.3.0: {} + picocolors@1.1.1: {} picomatch@4.0.7: {} @@ -4883,6 +5254,8 @@ snapshots: redis-errors@1.2.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} reselect@5.3.0: {} @@ -4894,6 +5267,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rettime@0.11.11: {} + rolldown@1.2.7: dependencies: '@oxc-project/types': 0.148.0 @@ -4933,6 +5308,8 @@ snapshots: server-only@0.0.1: {} + set-cookie-parser@3.1.2: {} + setimmediate@1.0.5: {} sharp@0.35.4(@types/node@26.4.1): @@ -4970,6 +5347,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -5003,12 +5382,26 @@ snapshots: '@stablelib/base64': 1.0.1 fast-sha256: 1.3.0 + statuses@2.0.2: {} + std-env@4.2.0: {} + strict-event-emitter@0.5.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + strip-json-comments@5.0.3: {} strtok3@10.3.5: @@ -5022,6 +5415,8 @@ snapshots: symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} + tailwind-merge@3.6.0: {} tailwindcss@4.3.3: {} @@ -5073,6 +5468,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@5.9.0: + dependencies: + tagged-tag: 1.0.0 + typescript@7.0.2: optionalDependencies: '@typescript/typescript-aix-ppc64': 7.0.2 @@ -5102,6 +5501,8 @@ snapshots: undici@8.10.2: {} + until-async@3.0.2: {} + use-callback-ref@1.3.3(@types/react@19.2.18)(react@19.2.8): dependencies: react: 19.2.8 @@ -5146,10 +5547,10 @@ snapshots: tsx: 4.23.13 yaml: 2.9.0 - vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): + vitest@5.0.0(@types/node@26.4.1)(@vitest/coverage-v8@5.0.0)(jsdom@30.0.1(@noble/hashes@2.4.0))(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@types/chai': 5.2.3 - '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) + '@vitest/mocker': 5.0.0(msw@2.15.0(@types/node@26.4.1)(typescript@7.0.2))(vite@8.2.2(@types/node@26.4.1)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.13)(yaml@2.9.0)) chai: 6.2.2 es-module-lexer: 2.3.2 expect-type: 1.4.0 @@ -5200,13 +5601,33 @@ snapshots: win-guid@0.2.1: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrappy@1.0.2: {} xml-name-validator@5.0.0: {} xmlchars@2.2.0: {} + y18n@5.0.8: {} + yaml@2.9.0: optional: true + yargs-parser@21.1.1: {} + + yargs@17.7.3: + 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 + zod@4.5.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 27a66bd5..2016f622 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ allowBuilds: sharp: true "@parcel/watcher": true "@swc/core": true + msw: false minimumReleaseAge: 1440 diff --git a/src/app/admin/articles/[id]/page.tsx b/src/app/admin/articles/[id]/page.tsx index 9bac5f25..3f0d8f9f 100644 --- a/src/app/admin/articles/[id]/page.tsx +++ b/src/app/admin/articles/[id]/page.tsx @@ -63,6 +63,7 @@ export default async function EditArticle({

) : null} { diff --git a/src/app/admin/articles/new/page.tsx b/src/app/admin/articles/new/page.tsx index 4dc278e1..98888608 100644 --- a/src/app/admin/articles/new/page.tsx +++ b/src/app/admin/articles/new/page.tsx @@ -39,7 +39,7 @@ export default async function NewArticle({ Error: {error}

) : null} - + ); } diff --git a/src/app/admin/events/create/page.tsx b/src/app/admin/events/create/page.tsx index 65a14b23..2325653a 100644 --- a/src/app/admin/events/create/page.tsx +++ b/src/app/admin/events/create/page.tsx @@ -29,7 +29,7 @@ export default async function CreateEventPage() {

- + ); } diff --git a/src/app/admin/events/event-form.tsx b/src/app/admin/events/event-form.tsx index a8c9eea5..bb161c6e 100644 --- a/src/app/admin/events/event-form.tsx +++ b/src/app/admin/events/event-form.tsx @@ -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 & { 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 | undefined; - if (data?.id) { - window.location.href = `/admin/events/${data.id}`; + recovery.clear(); + form.reset(data); + if (result.id) { + router.push(`/admin/events/${result.id}`); } }, }); @@ -103,178 +137,188 @@ export function EventForm({ eventTypes, defaultValues }: EventFormProps) { } return ( -
- - - - {isEditing ? t("formEditTitle") : t("formDetailsTitle")} - - - -
-
- - - {form.formState.errors.title && ( -

- {form.formState.errors.title.message} -

- )} -
+ +
+ + + + {isEditing ? t("formEditTitle") : t("formDetailsTitle")} + + + + {recovery.panel} +
+
+ + + {form.formState.errors.title && ( +

+ {form.formState.errors.title.message} +

+ )} +
-
- - -
-
- -
- -