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/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 new file mode 100644 index 0000000..59105b2 --- /dev/null +++ b/src/lib/url-service.test.ts @@ -0,0 +1,178 @@ +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 + ); + + 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 + +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) + }) + + 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, +}) 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" }, + }, +})