From 2e2127851f2daf841313ef89e7c564dbe240fc9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Wed, 19 Aug 2026 00:34:10 +0000 Subject: [PATCH 1/2] test: add vitest + pg-mem test infrastructure Introduces the test harness (vitest, an in-memory Postgres via pg-mem, and `test`/`test:watch` scripts) with no real DB or secrets required, so future features can ship with unit tests. Included a UrlService smoke-test suite covering existing create/read/update/delete/search/sort/click-count behavior as the first consumer of the harness. Co-Authored-By: Claude Sonnet 5 --- package.json | 6 +- pnpm-lock.yaml | 999 ++++++++++++++++++++++++++++++++++++ src/lib/url-service.test.ts | 124 +++++ vitest.config.ts | 14 + 4 files changed, 1142 insertions(+), 1 deletion(-) create mode 100644 src/lib/url-service.test.ts create mode 100644 vitest.config.ts diff --git a/package.json b/package.json index fefcd0a..a88fd70 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "start": "next start", "check": "biome check", "check:fix": "biome check --write", + "test": "vitest run", + "test:watch": "vitest", "docker:build": "docker build -t tmsu-cc .", "docker:run": "docker run -p 6111:6111 tmsu-cc", "docker:dev": "docker-compose up --build", @@ -62,9 +64,11 @@ "@types/react": "^19", "@types/react-dom": "^19", "babel-plugin-react-compiler": "^1.0.0", + "pg-mem": "^3.0.14", "tailwindcss": "^4", "tw-animate-css": "^1.3.8", - "typescript": "^5" + "typescript": "^5", + "vitest": "^4.1.10" }, "packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0492c59..5fa13ea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 + pg-mem: + specifier: ^3.0.14 + version: 3.0.14 tailwindcss: specifier: ^4 version: 4.1.13 @@ -147,6 +150,9 @@ importers: typescript: specifier: ^5 version: 5.9.2 + vitest: + specifier: ^4.1.10 + version: 4.1.11(@types/node@24.3.1)(vite@8.2.1(@types/node@24.3.1)(jiti@2.5.1)) packages: @@ -200,24 +206,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.2.0': resolution: {integrity: sha512-6eoRdF2yW5FnW9Lpeivh7Mayhq0KDdaDMYOJnH9aT02KuSIX5V1HmWJCQQPwIQbhDh68Zrcpl8inRlTEan0SXw==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.2.0': resolution: {integrity: sha512-I5J85yWwUWpgJyC1CcytNSGusu2p9HjDnOPAFG4Y515hwRD0jpR9sT9/T1cKHtuCvEQ/sBvx+6zhz9l9wEJGAg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.2.0': resolution: {integrity: sha512-5UmQx/OZAfJfi25zAnAGHUMuOd+LOsliIt119x2soA2gLggQYrVPA+2kMUxR6Mw5M1deUF/AWWP2qpxgH7Nyfw==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.2.0': resolution: {integrity: sha512-n9a1/f2CwIDmNMNkFs+JI0ZjFnMO0jdOyGNtihgUNFnlmd84yIYY2KMTBmMV58ZlVHjgmY5Y6E1hVTnSRieggA==} @@ -306,89 +316,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -456,24 +482,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@15.5.7': resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@15.5.7': resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@15.5.7': resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@15.5.7': resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} @@ -494,6 +524,9 @@ packages: peerDependencies: openapi-types: '*' + '@oxc-project/types@0.144.0': + resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==} + '@polka/url@1.0.0-next.29': resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} @@ -840,6 +873,99 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@rolldown/binding-android-arm64@1.2.4': + resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.4': + resolution: {integrity: sha512-Dc5mPD8F5F/FS8i01syd7FTF6yB2fVthH/TRkjwJkzUK6EpoxHtqvZQP5Zwq80/5z19TWYHIg1KOHboCgVx/aQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.4': + resolution: {integrity: sha512-fpDm4oBo6SqLvWUYCmFhdde3U9KH2fRNNMeAnAPAIwxRL345xutL0EtEUcuoxsoazdJGv/MuDBQHlCDrtbvqOg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.4': + resolution: {integrity: sha512-rSJoreDE/HoIzoaib6MTp5jQtCTdMHKIvItAKT/ImS6Y6Ww76oUaeMyp4Vc/fAgd/ehji068IxetHXAnqUwN9A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + resolution: {integrity: sha512-/jm8OGHgn7oGaJu3i/qZI9spUGcJ+y/lk43ttQ/iO1tOd9NissG6o97bighBCiL+BKRngmcDuR6ikfwYdJmVuQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + resolution: {integrity: sha512-tIP06BeD9EqvECBrPZ+sqdPlYrT+aYaAiu1wYziVx5elRK/ftm33JxVDy2bXGbr6J0CrtirCkR87/X5a2euEng==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.4': + resolution: {integrity: sha512-Ql1Q0EQqVThvn9VAVlwNzsUvbSFtCMGjLpRRi4pk5i7NZZ4n5ISiLMjHYtus4VQ2PvkSw24zyaCVsiS+sXPj1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + resolution: {integrity: sha512-GjbjXD4XXfN19D0LZNbmiCBUoDiRACsYHr0yaIbbn8aFsXjHZifcYqu/W5Er5X2X990WjHXFrxarn5chzItorQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + resolution: {integrity: sha512-p5WR0NOwaRmJ/B1b6IjEFLLivwEsf3PrdBIhRbhTCQisbo2SvHHpG4ELB/+FgQNnB88LTOF86upmJmbvZdQ2lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.4': + resolution: {integrity: sha512-4/GyVjmhR+Tc6HLJvwc1sOhPqAZtySiSMesOZyX6JQ5XBxoTDEMKQzvo07NIK6nTon/SivlZqvhzvuVBNQhObQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.4': + resolution: {integrity: sha512-l9eeLsCNvPpmSXUej0etw/J1eqV0Jj1D5G/xG6YTijmE6dkv6E2QezgWbTfQk63v952DPqrjOCoiqxq7Bw0YUQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.4': + resolution: {integrity: sha512-e0F355MSTMm3+UOqtV3L24gFUp2N5m1f8L/7d56deik6va+AXdrt9F8LbzGpeWGWRbZEDq4m8NVnJDeBtf9DZg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + resolution: {integrity: sha512-AWLi0uBRYh6QlE7OKhiz+phZC0qwtij2QZmhmOdsLdFn64m7oMpooE9ICE3lhm9xMb4SpDo2WbHcxX1iFLFtqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.4': + resolution: {integrity: sha512-UwSDJOg3dqCAejWdxclJjCsh3Qq4vLYMDxmyHqo1btz3stK2VqgwNd3mm5tuIwzSlGIQ/1H9Hr+Zn09mrezNqQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@scalar/core@0.3.20': resolution: {integrity: sha512-bIlrePx41pSvjDcaJPa9YVVhbSm0N9SKQm2Fzl489S0bUVToyXIQtMFVR4i+BmXGjOcATm/66ELW4vdXRjHoRA==} engines: {node: '>=20'} @@ -859,6 +985,9 @@ packages: resolution: {integrity: sha512-+X10CCvG57nAqYbTGteiSzRFQcMYm7DLfCRMeEfiWQ9Bq2ladat17XsMSvkvwcfpOSlsoepWf3P5dErERUSOQQ==} engines: {node: '>=20'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} @@ -937,24 +1066,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.13': resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.13': resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.13': resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.13': resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} @@ -1040,6 +1173,15 @@ packages: zod: optional: true + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -1060,6 +1202,35 @@ packages: '@types/react@19.1.12': resolution: {integrity: sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==} + '@vitest/expect@4.1.11': + resolution: {integrity: sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==} + + '@vitest/mocker@4.1.11': + resolution: {integrity: sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.11': + resolution: {integrity: sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==} + + '@vitest/runner@4.1.11': + resolution: {integrity: sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==} + + '@vitest/snapshot@4.1.11': + resolution: {integrity: sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==} + + '@vitest/spy@4.1.11': + resolution: {integrity: sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==} + + '@vitest/utils@4.1.11': + resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} + acorn-walk@8.3.4: resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} engines: {node: '>=0.4.0'} @@ -1087,12 +1258,32 @@ packages: resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} engines: {node: '>=10'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + babel-plugin-react-compiler@1.0.0: resolution: {integrity: sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==} + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bind@1.0.9: + resolution: {integrity: sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001760: resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -1111,16 +1302,26 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} debounce@1.2.1: resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==} + define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + detect-libc@2.0.4: resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} engines: {node: '>=8'} @@ -1132,6 +1333,13 @@ packages: detect-node-es@1.1.0: resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -1142,6 +1350,21 @@ packages: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -1150,12 +1373,39 @@ packages: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + functional-red-black-tree@1.0.1: + resolution: {integrity: sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1164,10 +1414,22 @@ packages: resolution: {integrity: sha512-R1QfovbPsKmosqTnPoRFiJ7CF9MLRgb53ChvMZm+r4p76/+8yKDy17qLL2PKInORy2RkZZekuK0efYgmzTkXyQ==} engines: {node: '>=18'} + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + get-nonce@1.0.1: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -1175,13 +1437,30 @@ packages: resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==} engines: {node: '>=10'} + has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + immutable@4.3.9: + resolution: {integrity: sha512-ObHy4YN7ycwZOUCLI1/6svfyAFu7vL8RhAvVu/bh/RZW9EPlOyDaQ9jDQWCtdqzaXUjgXZCW1migtHE7YI7UGQ==} + is-plain-object@5.0.0: resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} engines: {node: '>=0.10.0'} + isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + itty-router@5.0.22: resolution: {integrity: sha512-9hmdGErWdYDOurGYxSbqLhy4EFReIwk71hMZTJ5b+zfa2zjMNV1ftFno2b8VjAQvX615gNB8Qxbl9JMRqHnIVA==} @@ -1200,53 +1479,122 @@ packages: resolution: {integrity: sha512-Hg5KRkyouEjek4jKFiFuvSedtPhuf0AYQK+Ko4hcz9ljePCOjdj6dUDmHoJiIHBeZ/wrsi+hMtC5E2dvGpQgnQ==} engines: {node: '>=17'} + json-stable-stringify@1.3.0: + resolution: {integrity: sha512-qtYiSSFlwot9XHtF9bD9c7rwKjr+RecWT//ZnPvSmEjpV5mmPOCN4j8UjY5hbjNkOwZ/jQv3J6R1/pL7RwgMsg==} + engines: {node: '>= 0.4'} + + jsonify@0.0.1: + resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + lightningcss-darwin-arm64@1.30.1: resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + lightningcss-darwin-x64@1.30.1: resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + lightningcss-freebsd-x64@1.30.1: resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + lightningcss-linux-arm-gnueabihf@1.30.1: resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + lightningcss-linux-arm64-gnu@1.30.1: resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -1254,16 +1602,36 @@ packages: cpu: [arm64] os: [win32] + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + lightningcss-win32-x64-msvc@1.30.1: resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + lightningcss@1.30.1: resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} engines: {node: '>= 12.0.0'} + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + lucide-react@0.552.0: resolution: {integrity: sha512-g9WCjmfwqbexSnZE+2cl21PCfXOcqnGeWeMTNAOGEfpPbm/ZF4YIq77Z8qWrxbu660EKuLB4nSLggoKnCb+isw==} peerDependencies: @@ -1272,6 +1640,13 @@ packages: magic-string@0.30.18: resolution: {integrity: sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -1285,6 +1660,12 @@ packages: engines: {node: '>=10'} hasBin: true + moment@2.30.1: + resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==} + + moo@0.5.3: + resolution: {integrity: sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==} + mrmime@2.0.1: resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} engines: {node: '>=10'} @@ -1294,11 +1675,20 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + nanoid@5.1.5: resolution: {integrity: sha512-Ir/+ZpE9fDsNH0hQ3C68uyThDXzYcim2EqcZ8zn8Chtt1iylPT9xXJB0kPCnqzgcEGikO9RxSrh63MsmVCU7Fw==} engines: {node: ^18 || >=20} hasBin: true + nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + next-themes@0.4.6: resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==} peerDependencies: @@ -1326,6 +1716,18 @@ packages: sass: optional: true + object-hash@2.2.0: + resolution: {integrity: sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw==} + engines: {node: '>= 6'} + + object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + openapi-types@12.1.3: resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} @@ -1336,6 +1738,9 @@ packages: resolution: {integrity: sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==} hasBin: true + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.2.7: resolution: {integrity: sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==} @@ -1346,6 +1751,41 @@ packages: resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} engines: {node: '>=4.0.0'} + pg-mem@3.0.14: + resolution: {integrity: sha512-G9m8OD0A+YS083smidSUJddTX2dEDPT8mRMG3sQGNiGfS/mkvAgd9Kf1/onD5633bFN7HcQK/Tn2x7qjBMFRUQ==} + peerDependencies: + '@mikro-orm/core': '>=4.5.3' + '@mikro-orm/postgresql': '>=4.5.3' + knex: '>=0.20' + kysely: '>=0.26' + mikro-orm: '*' + pg-promise: '>=10.8.7' + pg-server: ^0.1.5 + postgres: ^3.4.4 + slonik: '>=23.0.1' + typeorm: '>=0.2.29' + peerDependenciesMeta: + '@mikro-orm/core': + optional: true + '@mikro-orm/postgresql': + optional: true + knex: + optional: true + kysely: + optional: true + mikro-orm: + optional: true + pg-promise: + optional: true + pg-server: + optional: true + postgres: + optional: true + slonik: + optional: true + typeorm: + optional: true + pg-pool@3.10.1: resolution: {integrity: sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==} peerDependencies: @@ -1370,13 +1810,24 @@ packages: pgpass@1.0.5: resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + pgsql-ast-parser@12.0.2: + resolution: {integrity: sha512-1WWa96Sw6h4uv9GLw98EzH/+xoBTC8j2TwV/AMW3E+Ir/fHOu/jLLbj6kPiz3y2bGISTKNYvKWwHoqvQ5FLuAw==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -1404,6 +1855,13 @@ packages: qrcode-generator@1.5.2: resolution: {integrity: sha512-pItrW0Z9HnDBnFmgiNrY1uxRdri32Uh9EjNYLPVC2zZ3ZRIIEqBoDgm4DkvDwNNDHTK7FNkmr8zAa77BYc9xNw==} + railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + + randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + react-dom@19.1.0: resolution: {integrity: sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==} peerDependencies: @@ -1453,6 +1911,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + + rolldown@1.2.4: + resolution: {integrity: sha512-rSr7irW0K7QRWzjdJXqZowkcRdDtjRduh43rBltnVKd0VFq839l1lJoDvGJb6gl7+4rTTCrPWu+YfujUL8Ug7w==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -1461,10 +1928,17 @@ packages: engines: {node: '>=10'} hasBin: true + set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -1483,6 +1957,12 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -1522,6 +2002,21 @@ packages: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} @@ -1577,11 +2072,100 @@ packages: '@types/react': optional: true + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.11: + resolution: {integrity: sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.11 + '@vitest/browser-preview': 4.1.11 + '@vitest/browser-webdriverio': 4.1.11 + '@vitest/coverage-istanbul': 4.1.11 + '@vitest/coverage-v8': 4.1.11 + '@vitest/ui': 4.1.11 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + webpack-bundle-analyzer@4.10.1: resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==} engines: {node: '>= 10.13.0'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrap-ansi@9.0.2: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} @@ -1606,6 +2190,9 @@ packages: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yallist@5.0.0: resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} engines: {node: '>=18'} @@ -1894,6 +2481,8 @@ snapshots: transitivePeerDependencies: - '@types/json-schema' + '@oxc-project/types@0.144.0': {} + '@polka/url@1.0.0-next.29': {} '@radix-ui/number@1.1.1': {} @@ -2219,6 +2808,50 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@rolldown/binding-android-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.4': + optional: true + + '@rolldown/binding-darwin-x64@1.2.4': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.4': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.4': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.4': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.4': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.4': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.4': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.4': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + '@scalar/core@0.3.20': dependencies: '@scalar/types': 0.3.2 @@ -2240,6 +2873,8 @@ snapshots: type-fest: 5.0.0 zod: 4.1.11 + '@standard-schema/spec@1.1.0': {} + '@standard-schema/utils@0.3.0': {} '@swc/helpers@0.5.15': @@ -2364,6 +2999,15 @@ snapshots: next: 15.5.9(babel-plugin-react-compiler@1.0.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) zod: 3.24.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + '@types/json-schema@7.0.15': {} '@types/node@20.19.13': @@ -2388,6 +3032,47 @@ snapshots: dependencies: csstype: 3.1.3 + '@vitest/expect@4.1.11': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.11(vite@8.2.1(@types/node@24.3.1)(jiti@2.5.1))': + dependencies: + '@vitest/spy': 4.1.11 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1(@types/node@24.3.1)(jiti@2.5.1) + + '@vitest/pretty-format@4.1.11': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.11': + dependencies: + '@vitest/utils': 4.1.11 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + '@vitest/utils': 4.1.11 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.11': {} + + '@vitest/utils@4.1.11': + dependencies: + '@vitest/pretty-format': 4.1.11 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + acorn-walk@8.3.4: dependencies: acorn: 8.15.0 @@ -2411,12 +3096,33 @@ snapshots: dependencies: tslib: 2.8.1 + assertion-error@2.0.1: {} + babel-plugin-react-compiler@1.0.0: dependencies: '@babel/types': 7.28.5 + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bind@1.0.9: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + get-intrinsic: 1.3.0 + set-function-length: 1.2.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001760: {} + chai@6.2.2: {} + chownr@3.0.0: {} class-variance-authority@0.7.1: @@ -2433,18 +3139,36 @@ snapshots: clsx@2.1.1: {} + commander@2.20.3: {} + commander@7.2.0: {} + convert-source-map@2.0.0: {} + csstype@3.1.3: {} debounce@1.2.1: {} + define-data-property@1.1.4: + dependencies: + es-define-property: 1.0.1 + es-errors: 1.3.0 + gopd: 1.2.0 + detect-libc@2.0.4: {} detect-libc@2.1.2: {} detect-node-es@1.1.0: {} + discontinuous-range@1.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + duplexer@0.1.2: {} emoji-regex@10.5.0: {} @@ -2454,30 +3178,91 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.2.3 + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-module-lexer@2.3.2: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + escalade@3.2.0: {} escape-string-regexp@4.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + functional-red-black-tree@1.0.1: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.3.1: {} + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + graceful-fs@4.2.11: {} gzip-size@6.0.0: dependencies: duplexer: 0.1.2 + has-property-descriptors@1.0.2: + dependencies: + es-define-property: 1.0.1 + + has-symbols@1.1.0: {} + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + html-escaper@2.0.2: {} + immutable@4.3.9: {} + is-plain-object@5.0.0: {} + isarray@2.0.5: {} + itty-router@5.0.22: {} jiti@2.5.1: {} @@ -2494,36 +3279,79 @@ snapshots: transitivePeerDependencies: - '@types/json-schema' + json-stable-stringify@1.3.0: + dependencies: + call-bind: 1.0.9 + call-bound: 1.0.4 + isarray: 2.0.5 + jsonify: 0.0.1 + object-keys: 1.1.1 + + jsonify@0.0.1: {} + + lightningcss-android-arm64@1.33.0: + optional: true + lightningcss-darwin-arm64@1.30.1: optional: true + lightningcss-darwin-arm64@1.33.0: + optional: true + lightningcss-darwin-x64@1.30.1: optional: true + lightningcss-darwin-x64@1.33.0: + optional: true + lightningcss-freebsd-x64@1.30.1: optional: true + lightningcss-freebsd-x64@1.33.0: + optional: true + lightningcss-linux-arm-gnueabihf@1.30.1: optional: true + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + lightningcss-linux-arm64-gnu@1.30.1: optional: true + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + lightningcss-linux-arm64-musl@1.30.1: optional: true + lightningcss-linux-arm64-musl@1.33.0: + optional: true + lightningcss-linux-x64-gnu@1.30.1: optional: true + lightningcss-linux-x64-gnu@1.33.0: + optional: true + lightningcss-linux-x64-musl@1.30.1: optional: true + lightningcss-linux-x64-musl@1.33.0: + optional: true + lightningcss-win32-arm64-msvc@1.30.1: optional: true + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + lightningcss-win32-x64-msvc@1.30.1: optional: true + lightningcss-win32-x64-msvc@1.33.0: + optional: true + lightningcss@1.30.1: dependencies: detect-libc: 2.1.2 @@ -2539,6 +3367,26 @@ snapshots: lightningcss-win32-arm64-msvc: 1.30.1 lightningcss-win32-x64-msvc: 1.30.1 + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@6.0.0: + dependencies: + yallist: 4.0.0 + lucide-react@0.552.0(react@19.1.0): dependencies: react: 19.1.0 @@ -2547,6 +3395,12 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + math-intrinsics@1.1.0: {} + minipass@7.1.2: {} minizlib@3.0.2: @@ -2555,12 +3409,25 @@ snapshots: mkdirp@3.0.1: {} + moment@2.30.1: {} + + moo@0.5.3: {} + mrmime@2.0.1: {} nanoid@3.3.11: {} + nanoid@3.3.18: {} + nanoid@5.1.5: {} + nearley@2.20.1: + dependencies: + commander: 2.20.3 + moo: 0.5.3 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + next-themes@0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 @@ -2590,6 +3457,12 @@ snapshots: - '@babel/core' - babel-plugin-macros + object-hash@2.2.0: {} + + object-keys@1.1.1: {} + + obug@2.1.4: {} + openapi-types@12.1.3: {} openapi3-ts@2.0.2: @@ -2598,6 +3471,8 @@ snapshots: opener@1.5.2: {} + pathe@2.0.3: {} + pg-cloudflare@1.2.7: optional: true @@ -2605,6 +3480,16 @@ snapshots: pg-int8@1.0.1: {} + pg-mem@3.0.14: + dependencies: + functional-red-black-tree: 1.0.1 + immutable: 4.3.9 + json-stable-stringify: 1.3.0 + lru-cache: 6.0.0 + moment: 2.30.1 + object-hash: 2.2.0 + pgsql-ast-parser: 12.0.2 + pg-pool@3.10.1(pg@8.16.3): dependencies: pg: 8.16.3 @@ -2633,14 +3518,27 @@ snapshots: dependencies: split2: 4.2.0 + pgsql-ast-parser@12.0.2: + dependencies: + moo: 0.5.3 + nearley: 2.20.1 + picocolors@1.1.1: {} + picomatch@4.0.5: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 source-map-js: 1.2.1 + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -2663,6 +3561,13 @@ snapshots: qrcode-generator@1.5.2: {} + railroad-diagrams@1.0.0: {} + + randexp@0.4.6: + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + react-dom@19.1.0(react@19.1.0): dependencies: react: 19.1.0 @@ -2703,11 +3608,42 @@ snapshots: require-from-string@2.0.2: {} + ret@0.1.15: {} + + rolldown@1.2.4: + dependencies: + '@oxc-project/types': 0.144.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.4 + '@rolldown/binding-darwin-arm64': 1.2.4 + '@rolldown/binding-darwin-x64': 1.2.4 + '@rolldown/binding-freebsd-x64': 1.2.4 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.4 + '@rolldown/binding-linux-arm64-gnu': 1.2.4 + '@rolldown/binding-linux-arm64-musl': 1.2.4 + '@rolldown/binding-linux-ppc64-gnu': 1.2.4 + '@rolldown/binding-linux-s390x-gnu': 1.2.4 + '@rolldown/binding-linux-x64-gnu': 1.2.4 + '@rolldown/binding-linux-x64-musl': 1.2.4 + '@rolldown/binding-openharmony-arm64': 1.2.4 + '@rolldown/binding-win32-arm64-msvc': 1.2.4 + '@rolldown/binding-win32-x64-msvc': 1.2.4 + scheduler@0.26.0: {} semver@7.7.3: optional: true + set-function-length@1.2.2: + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.3.0 + gopd: 1.2.0 + has-property-descriptors: 1.0.2 + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -2740,6 +3676,8 @@ snapshots: '@img/sharp-win32-x64': 0.34.5 optional: true + siginfo@2.0.0: {} + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.29 @@ -2755,6 +3693,10 @@ snapshots: split2@4.2.0: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + string-width@7.2.0: dependencies: emoji-regex: 10.5.0 @@ -2787,6 +3729,17 @@ snapshots: mkdirp: 3.0.1 yallist: 5.0.0 + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + totalist@3.0.1: {} ts-deepmerge@6.2.1: {} @@ -2824,6 +3777,45 @@ snapshots: optionalDependencies: '@types/react': 19.1.12 + vite@8.2.1(@types/node@24.3.1)(jiti@2.5.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.4 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.3.1 + fsevents: 2.3.3 + jiti: 2.5.1 + + vitest@4.1.11(@types/node@24.3.1)(vite@8.2.1(@types/node@24.3.1)(jiti@2.5.1)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.3.1)(jiti@2.5.1)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@24.3.1)(jiti@2.5.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.3.1 + transitivePeerDependencies: + - msw + webpack-bundle-analyzer@4.10.1: dependencies: '@discoveryjs/json-ext': 0.5.7 @@ -2843,6 +3835,11 @@ snapshots: - bufferutil - utf-8-validate + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrap-ansi@9.0.2: dependencies: ansi-styles: 6.2.3 @@ -2855,6 +3852,8 @@ snapshots: y18n@5.0.8: {} + yallist@4.0.0: {} + yallist@5.0.0: {} yaml@1.10.2: {} diff --git a/src/lib/url-service.test.ts b/src/lib/url-service.test.ts new file mode 100644 index 0000000..316feee --- /dev/null +++ b/src/lib/url-service.test.ts @@ -0,0 +1,124 @@ +import type { Pool } from "pg" +import { newDb } from "pg-mem" +import { beforeEach, describe, expect, it, vi } from "vitest" + +const SCHEMA_SQL = ` + CREATE TABLE urls ( + id SERIAL PRIMARY KEY, + is_custom BOOLEAN NOT NULL DEFAULT FALSE, + original_url TEXT NOT NULL, + short_code VARCHAR(25) UNIQUE NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + click_count INTEGER DEFAULT 0 + ); +` + +let pool: Pool + +vi.mock("./db", () => ({ + getPool: () => pool, +})) + +async function freshPool() { + const mem = newDb() + const pg = mem.adapters.createPg() + const p = new pg.Pool() as unknown as Pool + await p.query(SCHEMA_SQL) + return p +} + +describe("UrlService", () => { + beforeEach(async () => { + pool = await freshPool() + }) + + it("creates a short URL with a random code when none is given", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + + const record = await service.createShortUrl("https://example.com") + + expect(record.original_url).toBe("https://example.com") + expect(record.is_custom).toBe(false) + expect(record.short_code).toHaveLength(8) + }) + + it("creates a short URL with a custom code and rejects a duplicate", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + + const record = await service.createShortUrl( + "https://example.com", + "my-code" + ) + expect(record.short_code).toBe("my-code") + expect(record.is_custom).toBe(true) + + await expect( + service.createShortUrl("https://other.com", "my-code") + ).rejects.toThrow(/already exists/) + }) + + it("looks up a URL by short code", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com", "abc") + + const found = await service.getUrlByShortCode("abc") + expect(found?.original_url).toBe("https://example.com") + + const missing = await service.getUrlByShortCode("nope") + expect(missing).toBeNull() + }) + + it("updates the destination URL", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com", "abc") + + const updated = await service.updateUrl("abc", "https://updated.com") + expect(updated?.original_url).toBe("https://updated.com") + }) + + it("deletes a URL", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com", "abc") + + expect(await service.deleteUrl("abc")).toBe(true) + expect(await service.getUrlByShortCode("abc")).toBeNull() + expect(await service.deleteUrl("abc")).toBe(false) + }) + + it("increments the click count", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com", "abc") + + await service.incrementClickCount("abc") + await service.incrementClickCount("abc") + + const found = await service.getUrlByShortCode("abc") + expect(found?.click_count).toBe(2) + }) + + it("paginates, searches and sorts results", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com/one", "one") + await service.createShortUrl("https://example.com/two", "two") + await service.createShortUrl("https://other.com", "three") + + const bySearch = await service.getAllUrls({ search: "example" }) + expect(bySearch.pagination.total).toBe(2) + + const page1 = await service.getAllUrls({ + limit: 2, + sortBy: "short_code", + sortOrder: "asc", + }) + expect(page1.urls.map((u) => u.short_code)).toEqual(["one", "three"]) + expect(page1.pagination.totalPages).toBe(2) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..e54d1ed --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path" +import { defineConfig } from "vitest/config" + +export default defineConfig({ + resolve: { + alias: { "@": resolve(__dirname, "./src") }, + }, + test: { + environment: "node", + // Skip @t3-oss/env validation so importing modules doesn't require real + // secrets/DB config, and so db.ts skips its startup migration side effect. + env: { SKIP_ENV_VALIDATION: "true" }, + }, +}) From 241e8ea3b8ee6d5a68215640b02ba8ee5bf17c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriele=20Vigan=C3=B2?= Date: Wed, 19 Aug 2026 00:48:00 +0000 Subject: [PATCH 2/2] feat: add tagging system with color-coded badges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets each URL carry any number of free-text tags (a new url_tags junction table), with a deterministic color palette so the same tag name always renders the same badge color. Adds tag chip inputs to the create/edit dialogs, a tag filter in the dashboard, and getAllTags/addTag/removeTag endpoints alongside a `tag` filter on GET /urls. Scoped to tagging only — does not port vigab.cc's bundled "starred" toggle (a separate favoriting feature layered on the same commit upstream) since it would repurpose the existing "Show Custom Only" filter's behavior. Builds on the vitest/pg-mem test infrastructure branch so UrlService's existing test suite stays green (tag sync now runs on every update). Co-Authored-By: Claude Sonnet 5 --- src/app/api/[...ts-rest]/route.ts | 45 ++++++++- src/components/create-url-dialog.tsx | 98 +++++++++++++++++- src/components/dashboard.tsx | 61 +++++++++++- src/components/edit-url-dialog.tsx | 105 ++++++++++++++++++- src/components/url-record-row.tsx | 39 +++++++- src/hooks/urls.ts | 22 ++++ src/lib/actions.ts | 6 +- src/lib/contract.ts | 29 +++++- src/lib/db.ts | 23 ++++- src/lib/schemas.ts | 3 + src/lib/url-service.test.ts | 54 ++++++++++ src/lib/url-service.ts | 144 +++++++++++++++++++++++---- src/lib/utils.ts | 26 +++++ src/lib/validations.ts | 7 ++ 14 files changed, 628 insertions(+), 34 deletions(-) diff --git a/src/app/api/[...ts-rest]/route.ts b/src/app/api/[...ts-rest]/route.ts index 0a540f6..a3ea1f0 100644 --- a/src/app/api/[...ts-rest]/route.ts +++ b/src/app/api/[...ts-rest]/route.ts @@ -13,6 +13,7 @@ const handler = createNextHandler( sortBy: query.sortBy, sortOrder: query.sortOrder, customOnly: query.customOnly, + tag: query.tag, }) return { status: 200, @@ -23,7 +24,8 @@ const handler = createNextHandler( try { const urlRecord = await urlService.createShortUrl( body.url, - body.shortCode + body.shortCode, + body.tags ) return { status: 201, @@ -53,7 +55,11 @@ const handler = createNextHandler( } }, updateUrl: async ({ params, body }) => { - const urlRecord = await urlService.updateUrl(params.shortCode, body.url) + const urlRecord = await urlService.updateUrl( + params.shortCode, + body.url, + body.tags + ) if (!urlRecord) { return { status: 404, @@ -78,6 +84,41 @@ const handler = createNextHandler( body: undefined, } }, + getAllTags: async () => { + const tags = await urlService.getAllTags() + return { + status: 200, + body: tags, + } + }, + addTag: async ({ params, body }) => { + const urlRecord = await urlService.getUrlByShortCode(params.shortCode) + if (!urlRecord) { + return { + status: 404, + body: { error: "URL not found" }, + } + } + await urlService.addTag(urlRecord.id, body.tagName) + return { + status: 201, + body: undefined, + } + }, + removeTag: async ({ params }) => { + const urlRecord = await urlService.getUrlByShortCode(params.shortCode) + if (!urlRecord) { + return { + status: 404, + body: { error: "URL not found" }, + } + } + await urlService.removeTag(urlRecord.id, params.tagName) + return { + status: 204, + body: undefined, + } + }, }, { handlerType: "app-router", diff --git a/src/components/create-url-dialog.tsx b/src/components/create-url-dialog.tsx index f1e322e..4e9d8b5 100644 --- a/src/components/create-url-dialog.tsx +++ b/src/components/create-url-dialog.tsx @@ -2,8 +2,9 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react" import { getZodConstraint, parseWithZod } from "@conform-to/zod" +import { X } from "lucide-react" import { nanoid } from "nanoid" -import { useActionState, useCallback } from "react" +import { useActionState, useCallback, useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -18,6 +19,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { env } from "@/env" import { createUrl } from "@/lib/actions" +import { getTagColor } from "@/lib/utils" import { createUrlSchema } from "@/lib/validations" import { RandomText } from "./random-text" @@ -27,6 +29,91 @@ interface CreateUrlDialogProps { onSuccess: () => void } +/** Inline tag chip input — Enter or comma adds a tag */ +function TagChipInput({ + tags, + onChange, +}: { + tags: string[] + onChange: (tags: string[]) => void +}) { + const [input, setInput] = useState("") + const [error, setError] = useState(null) + + const add = () => { + const trimmed = input.trim().replace(/,+$/, "") + if (!trimmed) return + if (trimmed.length > 50) { + setError("Tag must be at most 50 characters") + return + } + if (tags.includes(trimmed)) { + setError("Tag already added") + return + } + onChange([...tags, trimmed]) + setInput("") + setError(null) + } + + const remove = (tag: string) => onChange(tags.filter((t) => t !== tag)) + + return ( +
+
+ { + setInput(e.target.value) + setError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + add() + } + }} + placeholder="e.g. events, forms…" + className="flex-1" + maxLength={51} + /> + +
+ {error &&

{error}

} + {tags.length > 0 && ( +
+ {tags.map((tag) => { + const c = getTagColor(tag) + return ( + + {tag} + + + ) + })} +
+ )} +
+ ) +} + export function CreateUrlDialog({ open, onOpenChange, @@ -36,6 +123,7 @@ export function CreateUrlDialog({ error: null, lastResult: null, }) + const [tags, setTags] = useState([]) const [form, fields] = useForm({ lastResult, constraint: getZodConstraint(createUrlSchema), @@ -47,6 +135,7 @@ export function CreateUrlDialog({ toast.error(`Error creating URL: ${error}`) } else { toast.success("Short URL created successfully!") + setTags([]) } onSuccess() }, @@ -104,6 +193,13 @@ export function CreateUrlDialog({ title="Short code can only contain letters, numbers, hyphens and underscores (2-20 characters)" /> +
+ + +
+ {tags.map((tag, i) => ( + + ))}
If you leave Short Code empty, a random one will be auto-generated upon submission. diff --git a/src/components/dashboard.tsx b/src/components/dashboard.tsx index 2c8af10..db139bf 100644 --- a/src/components/dashboard.tsx +++ b/src/components/dashboard.tsx @@ -1,7 +1,7 @@ "use client" import { SiGithub as Github } from "@icons-pack/react-simple-icons" -import { FileCodeCorner, Plus, Search, Star } from "lucide-react" +import { FileCodeCorner, Plus, Search, Star, Tag, X } from "lucide-react" import Image from "next/image" import Link from "next/link" import { useState } from "react" @@ -32,9 +32,9 @@ import { TableRow, } from "@/components/ui/table" import { env } from "@/env" -import { useUrls } from "@/hooks/urls" +import { useAllTags, useUrls } from "@/hooks/urls" import type { UrlRecord, UrlsQueryParams } from "@/lib/schemas" -import { copyToClipboard, makeShortUrl } from "@/lib/utils" +import { copyToClipboard, getTagColor, makeShortUrl } from "@/lib/utils" import { CreateUrlDialog } from "./create-url-dialog" import { type EditDialogState, EditUrlDialog } from "./edit-url-dialog" import { PaginationControls } from "./pagination" @@ -58,6 +58,7 @@ export function Dashboard() { } const { urls, pagination, loading, refetch } = useUrls(queryParams) + const { tags: allTags, refetch: refetchTags } = useAllTags() const [createDialogOpen, setCreateDialogOpen] = useState(false) const [editDialog, setEditDialog] = useState({ open: false }) @@ -74,6 +75,14 @@ export function Dashboard() { })) } + const handleTagFilter = (tag: string) => { + setQueryParams((prev) => ({ + ...prev, + tag: tag === "__all__" ? undefined : tag, + page: 1, + })) + } + const handleSortChange = (value: string) => { const [sortBy, sortOrder] = value.split("-") as [ UrlsQueryParams["sortBy"], @@ -114,6 +123,7 @@ export function Dashboard() { } const currentSort = `${qp.sortBy}-${qp.sortOrder}` + const activeTag = qp.tag return (
@@ -194,6 +204,49 @@ export function Dashboard() { + + {allTags.length > 0 && ( +
+ + + {activeTag && ( + + )} +
+ )} + { refetch() + refetchTags() setCreateDialogOpen(false) }} /> @@ -296,6 +350,7 @@ export function Dashboard() { onClose={() => setEditDialog({ open: false })} onSuccess={() => { refetch() + refetchTags() setEditDialog({ open: false }) }} /> diff --git a/src/components/edit-url-dialog.tsx b/src/components/edit-url-dialog.tsx index d2e47e0..ebd79d6 100644 --- a/src/components/edit-url-dialog.tsx +++ b/src/components/edit-url-dialog.tsx @@ -2,7 +2,8 @@ import { getFormProps, getInputProps, useForm } from "@conform-to/react" import { getZodConstraint, parseWithZod } from "@conform-to/zod" -import { useActionState } from "react" +import { X } from "lucide-react" +import { useActionState, useEffect, useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { @@ -17,7 +18,7 @@ import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { editUrl } from "@/lib/actions" import type { UrlRecord } from "@/lib/schemas" -import { makeShortUrl } from "@/lib/utils" +import { getTagColor, makeShortUrl } from "@/lib/utils" import { editUrlSchema } from "@/lib/validations" export type EditDialogState = @@ -61,6 +62,36 @@ export function EditUrlDialog({ shouldRevalidate: "onInput", }) + const [tags, setTags] = useState([]) + const [tagInput, setTagInput] = useState("") + const [tagError, setTagError] = useState(null) + + const urlId = state.open ? state.url.id : 0 + // biome-ignore lint/correctness/useExhaustiveDependencies: reset tags only when the dialog opens or a different URL is loaded + useEffect(() => { + if (state.open) setTags(state.url.tags ?? []) + setTagInput("") + setTagError(null) + }, [state.open, urlId]) + + const addTag = () => { + const trimmed = tagInput.trim().replace(/,+$/, "") + if (!trimmed) return + if (trimmed.length > 50) { + setTagError("Tag must be at most 50 characters") + return + } + if (tags.includes(trimmed)) { + setTagError("Tag already added") + return + } + setTags((prev) => [...prev, trimmed]) + setTagInput("") + setTagError(null) + } + const removeTag = (tag: string) => + setTags((prev) => prev.filter((t) => t !== tag)) + return ( !open && onClose()}> {state.open && ( @@ -109,6 +140,76 @@ export function EditUrlDialog({ className="col-span-3" />
+
+ +
+
+ { + setTagInput(e.target.value) + setTagError(null) + }} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === ",") { + e.preventDefault() + addTag() + } + }} + placeholder="e.g. events, forms…" + className="flex-1" + maxLength={51} + /> + +
+ {tagError && ( +

{tagError}

+ )} + {tags.length > 0 && ( +
+ {tags.map((tag) => { + const c = getTagColor(tag) + return ( + + {tag} + + + ) + })} +
+ )} +
+ {tags.map((tag, i) => ( + + ))} +
+ {url.tags && url.tags.length > 0 && ( +
+ +
+ )}
-
+
{shortUrl} + diff --git a/src/hooks/urls.ts b/src/hooks/urls.ts index 25b8172..1cf26d2 100644 --- a/src/hooks/urls.ts +++ b/src/hooks/urls.ts @@ -11,6 +11,7 @@ async function fetchUrls(params: UrlsQueryParams) { if (params.sortBy) queryParams.set("sortBy", params.sortBy) if (params.sortOrder) queryParams.set("sortOrder", params.sortOrder) if (params.customOnly) queryParams.set("customOnly", "true") + if (params.tag) queryParams.set("tag", params.tag) const response = await fetch(`/api/urls?${queryParams.toString()}`) @@ -50,3 +51,24 @@ export function useUrls(params: UrlsQueryParams = {}) { refetch: query.refetch, } } + +async function fetchAllTags(): Promise { + const response = await fetch("/api/tags") + if (!response.ok) { + throw new Error("Failed to fetch tags") + } + return response.json() +} + +export function useAllTags() { + const query = useQuery({ + queryKey: ["tags"], + queryFn: fetchAllTags, + staleTime: 1000 * 60, + }) + + return { + tags: query.data ?? [], + refetch: query.refetch, + } +} diff --git a/src/lib/actions.ts b/src/lib/actions.ts index e28f67c..123b5f7 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -23,7 +23,8 @@ export async function createUrl( try { await urlService.createShortUrl( submission.value.url, - submission.value.shortCode + submission.value.shortCode, + submission.value.tags ) result.error = null } catch (error) { @@ -51,7 +52,8 @@ export async function editUrl( try { await urlService.updateUrl( submission.value.shortCode, - submission.value.url + submission.value.url, + submission.value.tags ) result.error = null } catch (error) { diff --git a/src/lib/contract.ts b/src/lib/contract.ts index 3d180c6..679efdb 100644 --- a/src/lib/contract.ts +++ b/src/lib/contract.ts @@ -1,7 +1,7 @@ import { initContract } from "@ts-rest/core" import z from "zod" import { GetUrlsQueryParams, PaginatedUrlsResponse, URLRecord } from "./schemas" -import { createUrlSchema } from "./validations" +import { createUrlSchema, tagSchema } from "./validations" const c = initContract() @@ -56,4 +56,31 @@ export const contract = c.router({ }, summary: "Delete a short URL", }, + getAllTags: { + method: "GET", + path: "/tags", + responses: { + 200: z.array(z.string()), + }, + summary: "Get all distinct tags in use", + }, + addTag: { + method: "POST", + path: "/urls/:shortCode/tags", + body: tagSchema, + responses: { + 201: z.void(), + 404: APIError, + }, + summary: "Add a tag to a URL", + }, + removeTag: { + method: "DELETE", + path: "/urls/:shortCode/tags/:tagName", + responses: { + 204: z.void(), + 404: APIError, + }, + summary: "Remove a tag from a URL", + }, }) diff --git a/src/lib/db.ts b/src/lib/db.ts index 8d4c357..1203864 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -35,7 +35,7 @@ async function initDatabase() { updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, click_count INTEGER DEFAULT 0 ); - + CREATE INDEX IF NOT EXISTS idx_short_code ON urls(short_code); CREATE INDEX IF NOT EXISTS idx_created_at ON urls(created_at); CREATE INDEX IF NOT EXISTS idx_updated_at ON urls(updated_at); @@ -49,6 +49,24 @@ async function initDatabase() { } } +async function migrateDatabase() { + const pool = getPool() + + try { + // Multi-tag junction table: a URL can have any number of free-text tags + await pool.query(` + CREATE TABLE IF NOT EXISTS url_tags ( + url_id INTEGER NOT NULL REFERENCES urls(id) ON DELETE CASCADE, + tag_name VARCHAR(50) NOT NULL, + PRIMARY KEY (url_id, tag_name) + ); + CREATE INDEX IF NOT EXISTS idx_url_tags_tag_name ON url_tags(tag_name); + `) + } catch (error) { + console.error("Error running database migrations:", error) + } +} + let init = false // Skip initialization if env where not validated // this doesn't prevent DB calls to be made, but if the env is not validated @@ -57,8 +75,9 @@ let init = false if (!init && !process.env.SKIP_ENV_VALIDATION) { init = true initDatabase() + .then(() => migrateDatabase()) .then(() => { - console.log("Database initialized successfully") + console.log("Database initialized and migrated successfully") }) .catch((error) => { console.error("Error during database initialization:", error) diff --git a/src/lib/schemas.ts b/src/lib/schemas.ts index 5edff01..260f58f 100644 --- a/src/lib/schemas.ts +++ b/src/lib/schemas.ts @@ -3,6 +3,7 @@ import { z } from "zod" export const URLRecord = z.object({ id: z.coerce.number(), is_custom: z.boolean(), + tags: z.array(z.string()).optional().default([]), original_url: z.string().url(), short_code: z.string().max(25), created_at: z.coerce.date(), @@ -34,6 +35,8 @@ export const GetUrlsQueryParams = z.object({ .default("created_at"), sortOrder: z.enum(["asc", "desc"]).optional().default("desc"), customOnly: z.coerce.boolean().optional().default(false), + // filter by a single tag name (URL appears if it owns that tag) + tag: z.string().optional(), }) export type PaginatedUrlsResponse = z.infer diff --git a/src/lib/url-service.test.ts b/src/lib/url-service.test.ts index 316feee..59105b2 100644 --- a/src/lib/url-service.test.ts +++ b/src/lib/url-service.test.ts @@ -12,6 +12,12 @@ const SCHEMA_SQL = ` updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, click_count INTEGER DEFAULT 0 ); + + CREATE TABLE url_tags ( + url_id INTEGER NOT NULL REFERENCES urls(id) ON DELETE CASCADE, + tag_name VARCHAR(50) NOT NULL, + PRIMARY KEY (url_id, tag_name) + ); ` let pool: Pool @@ -121,4 +127,52 @@ describe("UrlService", () => { expect(page1.urls.map((u) => u.short_code)).toEqual(["one", "three"]) expect(page1.pagination.totalPages).toBe(2) }) + + it("creates a URL with tags and lists all distinct tags", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + + const record = await service.createShortUrl("https://example.com", "abc", [ + "work", + "personal", + ]) + expect(record.tags).toEqual(["personal", "work"]) + expect(await service.getAllTags()).toEqual(["personal", "work"]) + }) + + it("adds and removes tags from an existing URL", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + const record = await service.createShortUrl("https://example.com", "abc") + + await service.addTag(record.id, "work") + expect(await service.getTagsForUrl(record.id)).toEqual(["work"]) + + expect(await service.removeTag(record.id, "work")).toBe(true) + expect(await service.getTagsForUrl(record.id)).toEqual([]) + }) + + it("reconciles tags to an explicit list on update, including clearing them", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com", "abc", ["work"]) + + const updated = await service.updateUrl("abc", "https://example.com", [ + "personal", + ]) + expect(updated?.tags).toEqual(["personal"]) + + const cleared = await service.updateUrl("abc", "https://example.com", []) + expect(cleared?.tags).toEqual([]) + }) + + it("filters URLs by tag", async () => { + const { UrlService } = await import("./url-service") + const service = new UrlService() + await service.createShortUrl("https://example.com/one", "one", ["work"]) + await service.createShortUrl("https://example.com/two", "two", ["personal"]) + + const filtered = await service.getAllUrls({ tag: "work" }) + expect(filtered.urls.map((u) => u.short_code)).toEqual(["one"]) + }) }) diff --git a/src/lib/url-service.ts b/src/lib/url-service.ts index 63f5951..c61adc5 100644 --- a/src/lib/url-service.ts +++ b/src/lib/url-service.ts @@ -3,6 +3,7 @@ import { getPool } from "./db" import { type GetUrlsQueryParams, type PaginatedUrlsResponse, + URLRecord, URLRecords, type UrlRecord, } from "./schemas" @@ -12,7 +13,8 @@ export class UrlService { async createShortUrl( originalUrl: string, - customShortCode?: string + customShortCode?: string, + tags?: string[] ): Promise { let shortCode: string let isCustom = true @@ -43,14 +45,20 @@ export class UrlService { shortCode, isCustom, ]) - return result.rows[0] + const urlRecord = result.rows[0] + + for (const tag of tags ?? []) { + await this.addTag(urlRecord.id, tag) + } + + return this.attachTags(urlRecord) } async getUrlByShortCode(shortCode: string): Promise { const query = "SELECT * FROM urls WHERE short_code = $1" const result = await this.pool.query(query, [shortCode]) - console.log("Query result:", result.rows) - return result.rows[0] || null + if (!result.rows[0]) return null + return this.attachTags(result.rows[0]) } async getAllUrls( @@ -63,6 +71,7 @@ export class UrlService { sortBy = "created_at", customOnly = false, sortOrder = "desc", + tag, } = options const sb = [ @@ -75,29 +84,45 @@ export class UrlService { : "created_at" const offset = (page - 1) * limit - const queryParams = [!search, `%${search}%`, !customOnly, limit, offset] + // $1 = no-search flag, $2 = search pattern, + // $3 = no-customOnly flag, $4 = tag filter (null = all), $5 = limit, $6 = offset + const queryParams = [ + !search, + `%${search}%`, + !customOnly, + tag ?? null, + limit, + offset, + ] + + // Filtering by tag uses a plain LEFT JOIN restricted to the requested tag + // name (not a correlated subquery), since (url_id, tag_name) is unique so + // it can't multiply rows. + const fromAndWhere = ` + FROM urls u + LEFT JOIN url_tags mt ON mt.url_id = u.id AND mt.tag_name = $4 + WHERE ($1 OR (u.original_url ILIKE $2 OR u.short_code ILIKE $2)) + AND ($3 OR u.is_custom = TRUE) + AND ($4::text IS NULL OR mt.tag_name IS NOT NULL) + ` const [dataResult, totals] = await Promise.all([ this.pool.query( ` - SELECT * FROM urls - WHERE ($1 OR (original_url ILIKE $2 OR short_code ILIKE $2)) AND ($3 OR is_custom = TRUE) + SELECT u.* ${fromAndWhere} ORDER BY ${sb} ${sortOrder === "asc" ? "ASC" : "DESC"} - LIMIT $4 OFFSET $5 + LIMIT $5 OFFSET $6 `, queryParams ), this.pool.query( - ` - SELECT COUNT(*) FROM urls - WHERE ($1 OR (original_url ILIKE $2 OR short_code ILIKE $2)) AND ($3 OR is_custom = TRUE) - `, - queryParams.slice(0, 3) + `SELECT COUNT(*) ${fromAndWhere}`, + queryParams.slice(0, 4) ), ]) const total = parseInt(totals.rows[0].count, 10) - const urls = URLRecords.parse(dataResult.rows) + const urls = URLRecords.parse(await this.attachTagsToMany(dataResult.rows)) return { urls, @@ -112,17 +137,22 @@ export class UrlService { async updateUrl( shortCode: string, - originalUrl: string + originalUrl: string, + tags: string[] = [] ): Promise { const query = ` - UPDATE urls + UPDATE urls SET original_url = $1, updated_at = CURRENT_TIMESTAMP WHERE short_code = $2 RETURNING * ` const result = await this.pool.query(query, [originalUrl, shortCode]) - return result.rows[0] || null + if (!result.rows[0]) return null + + await this.syncTags(result.rows[0].id, tags) + + return this.attachTags(result.rows[0]) } async deleteUrl(shortCode: string): Promise { @@ -133,13 +163,89 @@ export class UrlService { async incrementClickCount(shortCode: string): Promise { const query = ` - UPDATE urls - SET click_count = click_count + 1 + UPDATE urls + SET click_count = click_count + 1 WHERE short_code = $1 ` await this.pool.query(query, [shortCode]) } + + /** Returns all distinct tag names in use, sorted */ + async getAllTags(): Promise { + const result = await this.pool.query( + "SELECT DISTINCT tag_name FROM url_tags ORDER BY tag_name" + ) + return result.rows.map((r: { tag_name: string }) => r.tag_name) + } + + async addTag(urlId: number, tagName: string): Promise { + await this.pool.query( + "INSERT INTO url_tags (url_id, tag_name) VALUES ($1, $2) ON CONFLICT DO NOTHING", + [urlId, tagName.trim()] + ) + } + + async removeTag(urlId: number, tagName: string): Promise { + const result = await this.pool.query( + "DELETE FROM url_tags WHERE url_id = $1 AND tag_name = $2", + [urlId, tagName] + ) + return (result.rowCount ?? 0) > 0 + } + + async getTagsForUrl(urlId: number): Promise { + const result = await this.pool.query( + "SELECT tag_name FROM url_tags WHERE url_id = $1 ORDER BY tag_name", + [urlId] + ) + return result.rows.map((r: { tag_name: string }) => r.tag_name) + } + + /** Reconciles a URL's tags to exactly match `tags` (adds new, removes missing) */ + private async syncTags(urlId: number, tags: string[]): Promise { + const current = new Set(await this.getTagsForUrl(urlId)) + const next = new Set(tags.map((t) => t.trim()).filter(Boolean)) + + for (const tag of next) { + if (!current.has(tag)) await this.addTag(urlId, tag) + } + for (const tag of current) { + if (!next.has(tag)) await this.removeTag(urlId, tag) + } + } + + private async attachTags(row: Record): Promise { + const tags = await this.getTagsForUrl(row.id as number) + return URLRecord.parse({ ...row, tags }) + } + + /** Batch-fetches tags for many rows in one query instead of one-per-row */ + private async attachTagsToMany( + rows: Record[] + ): Promise[]> { + if (rows.length === 0) return rows + + const ids = rows.map((r) => r.id as number) + const result = await this.pool.query( + "SELECT url_id, tag_name FROM url_tags WHERE url_id = ANY($1::int[]) ORDER BY tag_name", + [ids] + ) + const tagsByUrlId = new Map() + for (const { url_id, tag_name } of result.rows as { + url_id: number + tag_name: string + }[]) { + const list = tagsByUrlId.get(url_id) ?? [] + list.push(tag_name) + tagsByUrlId.set(url_id, list) + } + + return rows.map((row) => ({ + ...row, + tags: tagsByUrlId.get(row.id as number) ?? [], + })) + } } export const urlService = new UrlService() diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 90b7daf..c50617e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -39,3 +39,29 @@ export const copyToClipboard = async (text: string) => { export function makeShortUrl(url: UrlRecord): string { return `https://${env.NEXT_PUBLIC_DOMAIN}/${url.short_code}` } + +const TAG_PALETTE: { bg: string; text: string; border: string }[] = [ + { bg: "#dbeafe", text: "#1e40af", border: "#bfdbfe" }, // blue + { bg: "#d1fae5", text: "#065f46", border: "#a7f3d0" }, // green + { bg: "#fce7f3", text: "#9d174d", border: "#fbcfe8" }, // pink + { bg: "#fef3c7", text: "#92400e", border: "#fde68a" }, // amber + { bg: "#e0e7ff", text: "#3730a3", border: "#c7d2fe" }, // indigo + { bg: "#fee2e2", text: "#991b1b", border: "#fecaca" }, // red + { bg: "#ccfbf1", text: "#134e4a", border: "#99f6e4" }, // teal + { bg: "#f3e8ff", text: "#6b21a8", border: "#e9d5ff" }, // purple + { bg: "#ffedd5", text: "#9a3412", border: "#fed7aa" }, // orange + { bg: "#ecfccb", text: "#365314", border: "#d9f99d" }, // lime +] + +function tagHash(tag: string): number { + let h = 0 + for (let i = 0; i < tag.length; i++) { + h = (h * 31 + tag.charCodeAt(i)) >>> 0 + } + return h % TAG_PALETTE.length +} + +/** Deterministic color for a tag name — same tag always renders the same color */ +export function getTagColor(tag: string) { + return TAG_PALETTE[tagHash(tag)] +} diff --git a/src/lib/validations.ts b/src/lib/validations.ts index 0399c93..899d893 100644 --- a/src/lib/validations.ts +++ b/src/lib/validations.ts @@ -1,5 +1,7 @@ import { z } from "zod" +const tagValidator = z.string().min(1).max(50).trim() + export const editUrlSchema = z.object({ url: z.string().url("Invalid URL format"), shortCode: z @@ -10,8 +12,13 @@ export const editUrlSchema = z.object({ /^[a-zA-Z0-9_-]+$/, "Short code can only contain letters, numbers, hyphens and underscores" ), + tags: z.array(tagValidator).default([]), }) export const createUrlSchema = editUrlSchema.extend({ shortCode: editUrlSchema.shape.shortCode.optional(), }) + +export const tagSchema = z.object({ + tagName: tagValidator, +})