Initialize project

This commit is contained in:
woozu.shin
2026-01-28 15:33:47 +09:00
commit 3fe5424732
43 changed files with 6108 additions and 0 deletions

417
frontend/bun.lock Normal file
View File

@@ -0,0 +1,417 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "web-utils-frontend",
"dependencies": {
"@heroicons/vue": "^2.1.1",
"@types/uuid": "^11.0.0",
"axios": "^1.6.0",
"uuid": "^13.0.0",
"vue": "^3.4.0",
"vue-router": "^4.2.5",
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0",
},
},
},
"packages": {
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.6", "", { "dependencies": { "@babel/types": "^7.28.6" }, "bin": "./bin/babel-parser.js" }, "sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ=="],
"@babel/types": ["@babel/types@7.28.6", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="],
"@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="],
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="],
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="],
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="],
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="],
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="],
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="],
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="],
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="],
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="],
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="],
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="],
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="],
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="],
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="],
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="],
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="],
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="],
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="],
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@heroicons/vue": ["@heroicons/vue@2.2.0", "", { "peerDependencies": { "vue": ">= 3" } }, "sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="],
"@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="],
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.0", "", { "os": "android", "cpu": "arm" }, "sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.0", "", { "os": "android", "cpu": "arm64" }, "sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.0", "", { "os": "linux", "cpu": "none" }, "sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.0", "", { "os": "linux", "cpu": "x64" }, "sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.0", "", { "os": "none", "cpu": "arm64" }, "sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="],
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@volar/language-core": ["@volar/language-core@1.11.1", "", { "dependencies": { "@volar/source-map": "1.11.1" } }, "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw=="],
"@volar/source-map": ["@volar/source-map@1.11.1", "", { "dependencies": { "muggle-string": "^0.3.1" } }, "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg=="],
"@volar/typescript": ["@volar/typescript@1.11.1", "", { "dependencies": { "@volar/language-core": "1.11.1", "path-browserify": "^1.0.1" } }, "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ=="],
"@vue/compiler-core": ["@vue/compiler-core@3.5.27", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/shared": "3.5.27", "entities": "^7.0.0", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-gnSBQjZA+//qDZen+6a2EdHqJ68Z7uybrMf3SPjEGgG4dicklwDVmMC1AeIHxtLVPT7sn6sH1KOO+tS6gwOUeQ=="],
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.27", "", { "dependencies": { "@vue/compiler-core": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-oAFea8dZgCtVVVTEC7fv3T5CbZW9BxpFzGGxC79xakTr6ooeEqmRuvQydIiDAkglZEAd09LgVf1RoDnL54fu5w=="],
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.27", "", { "dependencies": { "@babel/parser": "^7.28.5", "@vue/compiler-core": "3.5.27", "@vue/compiler-dom": "3.5.27", "@vue/compiler-ssr": "3.5.27", "@vue/shared": "3.5.27", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-sHZu9QyDPeDmN/MRoshhggVOWE5WlGFStKFwu8G52swATgSny27hJRWteKDSUUzUH+wp+bmeNbhJnEAel/auUQ=="],
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.27", "", { "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-Sj7h+JHt512fV1cTxKlYhg7qxBvack+BGncSpH+8vnN+KN95iPIcqB5rsbblX40XorP+ilO7VIKlkuu3Xq2vjw=="],
"@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"@vue/language-core": ["@vue/language-core@1.8.27", "", { "dependencies": { "@volar/language-core": "~1.11.1", "@volar/source-map": "~1.11.1", "@vue/compiler-dom": "^3.3.0", "@vue/shared": "^3.3.0", "computeds": "^0.0.1", "minimatch": "^9.0.3", "muggle-string": "^0.3.1", "path-browserify": "^1.0.1", "vue-template-compiler": "^2.7.14" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA=="],
"@vue/reactivity": ["@vue/reactivity@3.5.27", "", { "dependencies": { "@vue/shared": "3.5.27" } }, "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ=="],
"@vue/runtime-core": ["@vue/runtime-core@3.5.27", "", { "dependencies": { "@vue/reactivity": "3.5.27", "@vue/shared": "3.5.27" } }, "sha512-fxVuX/fzgzeMPn/CLQecWeDIFNt3gQVhxM0rW02Tvp/YmZfXQgcTXlakq7IMutuZ/+Ogbn+K0oct9J3JZfyk3A=="],
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.27", "", { "dependencies": { "@vue/reactivity": "3.5.27", "@vue/runtime-core": "3.5.27", "@vue/shared": "3.5.27", "csstype": "^3.2.3" } }, "sha512-/QnLslQgYqSJ5aUmb5F0z0caZPGHRB8LEAQ1s81vHFM5CBfnun63rxhvE/scVb/j3TbBuoZwkJyiLCkBluMpeg=="],
"@vue/server-renderer": ["@vue/server-renderer@3.5.27", "", { "dependencies": { "@vue/compiler-ssr": "3.5.27", "@vue/shared": "3.5.27" }, "peerDependencies": { "vue": "3.5.27" } }, "sha512-qOz/5thjeP1vAFc4+BY3Nr6wxyLhpeQgAE/8dDtKo6a6xdk+L4W46HDZgNmLOBUDEkFXV3G7pRiUqxjX0/2zWA=="],
"@vue/shared": ["@vue/shared@3.5.27", "", {}, "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ=="],
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
"arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"axios": ["axios@1.13.4", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA=="],
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
"brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="],
"caniuse-lite": ["caniuse-lite@1.0.30001766", "", {}, "sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA=="],
"chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
"computeds": ["computeds@0.0.1", "", {}, "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"electron-to-chromium": ["electron-to-chromium@1.5.279", "", {}, "sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["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.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
"is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="],
"lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="],
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"muggle-string": ["muggle-string@0.3.1", "", {}, "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg=="],
"mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="],
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
"postcss-load-config": ["postcss-load-config@6.0.1", "", { "dependencies": { "lilconfig": "^3.1.1" }, "peerDependencies": { "jiti": ">=1.21.0", "postcss": ">=8.0.9", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "postcss", "tsx", "yaml"] }, "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g=="],
"postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="],
"postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="],
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sucrase": ["sucrase@3.35.1", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "tinyglobby": "^0.2.11", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw=="],
"supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
"tailwindcss": ["tailwindcss@3.4.19", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.7", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ=="],
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="],
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vue": ["vue@3.5.27", "", { "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", "@vue/runtime-dom": "3.5.27", "@vue/server-renderer": "3.5.27", "@vue/shared": "3.5.27" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw=="],
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-template-compiler": ["vue-template-compiler@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ=="],
"vue-tsc": ["vue-tsc@1.8.27", "", { "dependencies": { "@volar/typescript": "~1.11.1", "@vue/language-core": "1.8.27", "semver": "^7.5.4" }, "peerDependencies": { "typescript": "*" }, "bin": { "vue-tsc": "bin/vue-tsc.js" } }, "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg=="],
"chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tinyglobby/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
}
}

13
frontend/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Web Utils 2026</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-gray-50 text-gray-900">
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2780
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
frontend/package.json Normal file
View File

@@ -0,0 +1,40 @@
{
"name": "web-utils-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@faker-js/faker": "^10.2.0",
"@heroicons/vue": "^2.1.1",
"@types/uuid": "^11.0.0",
"axios": "^1.6.0",
"composerize": "^1.7.5",
"cronstrue": "^3.9.0",
"decomposerize": "^1.4.4",
"diff": "^8.0.3",
"js-yaml": "^4.1.1",
"jwt-decode": "^4.0.0",
"papaparse": "^5.5.3",
"qrcode": "^1.5.4",
"sql-formatter": "^15.7.0",
"svgo": "^4.0.0",
"uuid": "^13.0.0",
"vue": "^3.4.0",
"vue-diff": "^1.2.4",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.0",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"tailwindcss": "^3.3.5",
"typescript": "^5.2.0",
"vite": "^5.0.0",
"vue-tsc": "^1.8.0"
}
}

19
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,19 @@
<script setup lang="ts">
import GlobalNavBar from './components/GlobalNavBar.vue'
</script>
<template>
<div class="min-h-screen bg-gray-100 flex flex-col">
<GlobalNavBar />
<main class="flex-1 max-w-7xl w-full mx-auto p-6">
<router-view></router-view>
</main>
<footer class="bg-white border-t border-gray-200 mt-auto">
<div class="max-w-7xl mx-auto py-6 px-4 text-center text-sm text-gray-500">
&copy; 2026 Web Utils. Powered by Vue 3 & FastAPI.
</div>
</footer>
</div>
</template>

View File

@@ -0,0 +1,32 @@
<script setup lang="ts">
import { useRouter } from 'vue-router'
const router = useRouter()
</script>
<template>
<nav class="bg-gray-800 text-white shadow-md">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-16">
<!-- Logo / Home Link -->
<div class="flex items-center cursor-pointer" @click="router.push('/')">
<span class="text-xl font-bold tracking-tight">Web Utils 2026</span>
</div>
<!-- Navigation Links -->
<div class="hidden md:block">
<div class="ml-10 flex items-baseline space-x-4">
<router-link
to="/"
class="px-3 py-2 rounded-md text-sm font-medium hover:bg-gray-700 transition"
active-class="bg-gray-900 text-white"
>
Dashboard
</router-link>
<!-- Add more global links here if needed -->
</div>
</div>
</div>
</div>
</nav>
</template>

View File

@@ -0,0 +1,37 @@
<template>
<div class="h-full flex flex-col bg-gray-50 dark:bg-gray-900">
<!-- Header -->
<header class="bg-white dark:bg-gray-800 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div class="flex items-center justify-between">
<div>
<slot name="header">
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Tool Name</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Tool description goes here.</p>
</slot>
</div>
<div class="flex items-center space-x-3">
<slot name="actions"></slot>
</div>
</div>
</header>
<div class="flex-1 flex overflow-hidden">
<!-- Options Sidebar (Optional) -->
<aside v-if="$slots.options" class="w-80 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 overflow-y-auto p-6 flex-shrink-0 z-10">
<h2 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-4">Configuration</h2>
<slot name="options"></slot>
</aside>
<!-- Main Content -->
<main class="flex-1 overflow-auto p-6 relative">
<div class="max-w-[90%] mx-auto h-full">
<slot name="content"></slot>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
// ToolLayout.vue - Shared wrapper for all Web Utils tools
</script>

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createApp } from 'vue'
import VueDiff from 'vue-diff'
import 'vue-diff/dist/index.css'
import './style.css'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(router)
app.use(VueDiff)
app.mount('#app')

View File

@@ -0,0 +1,121 @@
import { createRouter, createWebHistory } from 'vue-router'
import Dashboard from '../views/Dashboard.vue'
// Tools
import UrlParser from '../views/tools/UrlParser.vue'
import JsonFormatter from '../views/tools/JsonFormatter.vue'
import UuidGen from '../views/tools/UuidGen.vue'
import PngCompressor from '../views/tools/PngCompressor.vue'
import PasswordGen from '../views/tools/PasswordGen.vue'
import JwtDebugger from '../views/tools/JwtDebugger.vue'
import CronGen from '../views/tools/CronGen.vue'
import SqlFormatter from '../views/tools/SqlFormatter.vue'
import DiffViewer from '../views/tools/DiffViewer.vue'
import QrGenerator from '../views/tools/QrGenerator.vue'
import Base64Encoder from '../views/tools/Base64Encoder.vue'
import JsonConverter from '../views/tools/JsonConverter.vue'
import RegexTester from '../views/tools/RegexTester.vue'
import DockerConverter from '../views/tools/DockerConverter.vue'
import MockDataGen from '../views/tools/MockDataGen.vue'
import SvgOptimizer from '../views/tools/SvgOptimizer.vue'
import VideoToGif from '../views/tools/VideoToGif.vue'
const routes = [
{
path: '/',
name: 'Dashboard',
component: Dashboard
},
{
path: '/tools/url-parser',
name: 'URL Encoder/Decoder',
component: UrlParser
},
{
path: '/tools/json-formatter',
name: 'JSON Formatter',
component: JsonFormatter
},
{
path: '/tools/py-uuid',
name: 'UUID Generator',
component: UuidGen
},
{
path: '/tools/png-compressor',
name: 'PNG Compressor',
component: PngCompressor
},
{
path: '/tools/password-generator',
name: 'Password Generator',
component: PasswordGen
},
{
path: '/tools/jwt-debugger',
name: 'JWT Debugger',
component: JwtDebugger
},
{
path: '/tools/cron-generator',
name: 'Cron Schedule Generator',
component: CronGen
},
{
path: '/tools/sql-formatter',
name: 'SQL Formatter',
component: SqlFormatter
},
{
path: '/tools/diff-viewer',
name: 'Diff Viewer',
component: DiffViewer
},
{
path: '/tools/qr-generator',
name: 'QR Code Generator',
component: QrGenerator
},
{
path: '/tools/base64-encoder',
name: 'Base64 File Encoder',
component: Base64Encoder
},
{
path: '/tools/json-converter',
name: 'JSON Converter',
component: JsonConverter
},
{
path: '/tools/regex-tester',
name: 'Regex Tester',
component: RegexTester
},
{
path: '/tools/docker-converter',
name: 'Docker Converter',
component: DockerConverter
},
{
path: '/tools/mock-data-generator',
name: 'Mock Data Generator',
component: MockDataGen
},
{
path: '/tools/svg-optimizer',
name: 'SVG Optimizer',
component: SvgOptimizer
},
{
path: '/tools/video-to-gif',
name: 'Video to GIF',
component: VideoToGif
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

14
frontend/src/style.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
input[type="text"],
input[type="number"],
input[type="password"],
input[type="email"],
textarea,
select {
@apply border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100;
}
}

View File

@@ -0,0 +1,68 @@
/**
* Converts an array of strings or objects to CSV and triggers a download.
* @param data Array of data (strings or objects)
* @param filename Name of the file to download (without extension)
* @param headers Optional headers for object arrays. If not provided, keys of the first object are used.
*/
export const downloadCsv = (data: any[], filename: string, headers?: string[]) => {
if (!data || data.length === 0) {
alert("No data to download.");
return;
}
// Add BOM for Excel compatibility with UTF-8
let csvContent = "\uFEFF";
// Helper to safely escape CSV fields
const escapeCsvField = (field: any): string => {
if (field === null || field === undefined) {
return '""';
}
const stringField = String(field);
// Escape double quotes by doubling them (" -> "")
return `"${stringField.replace(/"/g, '""')}"`;
};
// 1. Determine Headers
let csvHeaders: string[] = [];
const isSimpleArray = typeof data[0] !== 'object' || data[0] === null;
if (headers) {
csvHeaders = headers;
} else if (!isSimpleArray) {
csvHeaders = Object.keys(data[0]);
} else {
csvHeaders = ["Value"];
}
// Write Headers
csvContent += csvHeaders.map(escapeCsvField).join(",") + "\n";
// 2. Build Rows
data.forEach((row) => {
if (isSimpleArray) {
csvContent += escapeCsvField(row) + "\n";
} else {
const rowString = csvHeaders.map(header => {
return escapeCsvField(row[header]);
}).join(",");
csvContent += rowString + "\n";
}
});
// 3. Trigger Download
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
if (navigator.msSaveBlob) { // IE 10+
navigator.msSaveBlob(blob, `${filename}.csv`);
} else {
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `${filename}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url); // Clean up memory
}
};

View File

@@ -0,0 +1,121 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
interface Tool {
id: string;
name: string;
description: string;
tags: string[];
type: 'client' | 'server';
}
const router = useRouter()
const tools = ref<Tool[]>([])
const searchQuery = ref('')
const loading = ref(true)
// Fetch tools from Backend Registry
const fetchTools = async () => {
try {
const res = await fetch('/api/registry')
if (res.ok) {
tools.value = await res.json()
}
} catch (e) {
console.error("Failed to fetch registry:", e)
} finally {
loading.value = false
}
}
onMounted(() => {
fetchTools()
})
const filteredTools = computed(() => {
const q = searchQuery.value.toLowerCase()
return tools.value.filter(t =>
t.name.toLowerCase().includes(q) ||
t.description.toLowerCase().includes(q) ||
t.tags.some(tag => tag.toLowerCase().includes(q))
)
})
const navigateToTool = (toolId: string) => {
router.push(`/tools/${toolId}`)
}
const getTagColor = (type: string) => {
return type === 'server'
? 'bg-orange-50 text-orange-700 border-orange-200'
: 'bg-blue-50 text-blue-700 border-blue-200'
}
</script>
<template>
<div>
<div class="mb-12 text-center">
<h1 class="text-4xl font-extrabold text-gray-800 mb-4">Web Utils 2026</h1>
<p class="text-lg text-gray-600 max-w-2xl mx-auto">
A powerful collection of developer utilities, combining the speed of Vue 3 with the capabilities of Python.
</p>
</div>
<!-- Search -->
<div class="mb-10 max-w-2xl mx-auto">
<div class="relative">
<input
v-model="searchQuery"
type="text"
placeholder="Search for tools..."
class="w-full pl-12 pr-4 py-4 rounded-xl border border-gray-200 focus:ring-4 focus:ring-blue-100 focus:border-blue-500 shadow-sm text-lg transition"
>
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
<svg class="h-6 w-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path></svg>
</div>
</div>
</div>
<!-- Grid -->
<div v-if="loading" class="text-center py-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mx-auto"></div>
<p class="mt-4 text-gray-500">Loading tools...</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div
v-for="tool in filteredTools"
:key="tool.id"
@click="navigateToTool(tool.id)"
class="bg-white rounded-xl shadow-sm hover:shadow-lg hover:-translate-y-1 transition-all duration-200 cursor-pointer border border-gray-100 overflow-hidden flex flex-col h-full"
>
<div class="p-6 flex-1">
<div class="flex justify-between items-start mb-4">
<h3 class="text-xl font-bold text-gray-800">{{ tool.name }}</h3>
<span class="px-2 py-1 rounded-md text-xs font-bold uppercase tracking-wide border" :class="getTagColor(tool.type)">
{{ tool.type }}
</span>
</div>
<p class="text-gray-600 text-sm mb-4 line-clamp-2">{{ tool.description }}</p>
<div class="flex flex-wrap gap-2">
<span v-for="tag in tool.tags" :key="tag" class="px-2 py-1 bg-gray-100 text-gray-600 text-xs rounded font-medium">
#{{ tag }}
</span>
</div>
</div>
<div class="bg-gray-50 px-6 py-3 border-t border-gray-100 text-right group">
<span class="text-blue-600 text-sm font-medium group-hover:text-blue-800 transition flex items-center justify-end">
Open Tool <svg class="w-4 h-4 ml-1 transform group-hover:translate-x-1 transition" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 8l4 4m0 0l-4 4m4-4H3"></path></svg>
</span>
</div>
</div>
</div>
<div v-if="!loading && filteredTools.length === 0" class="text-center py-16 bg-white rounded-xl border border-dashed border-gray-300">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
<p class="mt-4 text-gray-500 text-lg">No tools found matching "{{ searchQuery }}"</p>
<button @click="searchQuery = ''" class="mt-2 text-blue-600 hover:text-blue-800 font-medium">Clear search</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,103 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Base64 File Encoder</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert files to Base64 strings for easy embedding.</p>
</template>
<template #content>
<div class="max-w-5xl mx-auto space-y-6">
<!-- Drop Zone -->
<div
class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors cursor-pointer"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent
>
<div class="space-y-2">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="text-gray-600 dark:text-gray-300">
<span class="font-medium text-blue-600 hover:text-blue-500">Upload a file</span> or drag and drop
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">Any file up to 5MB</p>
</div>
<input ref="fileInput" type="file" class="hidden" @change="handleFileSelect" />
</div>
<!-- Result -->
<div v-if="result" class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Base64 Output</h3>
<button
@click="copyToClipboard"
class="inline-flex items-center px-3 py-1.5 border border-transparent text-xs font-medium rounded-md text-blue-700 bg-blue-100 hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
<span v-if="copied">Copied!</span>
<span v-else>Copy</span>
</button>
</div>
<textarea
v-model="result"
readonly
rows="8"
class="w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs break-all"
></textarea>
<div class="text-xs text-gray-500">
Preview: <span class="font-mono">{{ result.substring(0, 50) }}...</span>
</div>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import ToolLayout from '../../components/ToolLayout.vue';
const fileInput = ref<HTMLInputElement | null>(null);
const result = ref('');
const copied = ref(false);
const triggerFileInput = () => {
fileInput.value?.click();
};
const processFile = (file: File) => {
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
result.value = e.target?.result as string;
};
reader.readAsDataURL(file);
};
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
processFile(target.files[0]);
}
};
const handleDrop = (event: DragEvent) => {
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
processFile(event.dataTransfer.files[0]);
}
};
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(result.value);
copied.value = true;
setTimeout(() => copied.value = false, 2000);
} catch (err) {
console.error('Failed to copy', err);
}
};
</script>

View File

@@ -0,0 +1,67 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Cron Schedule Generator</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate and explain cron expressions.</p>
</template>
<template #content>
<div class="max-w-4xl mx-auto space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Cron Expression</label>
<div class="relative">
<input
v-model="cronExpression"
type="text"
class="w-full px-4 py-3 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-lg"
placeholder="*/5 * * * *"
/>
</div>
</div>
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-md border border-blue-100 dark:border-blue-800">
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-1">Human Readable</h3>
<p class="text-lg text-blue-900 dark:text-blue-100 font-medium">
{{ humanReadable || 'Invalid cron expression' }}
</p>
</div>
<div class="space-y-4">
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300">Quick Presets</h3>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
<button v-for="preset in presets" :key="preset.val" @click="cronExpression = preset.val" class="px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700">
{{ preset.label }}
</button>
</div>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import cronstrue from 'cronstrue';
import ToolLayout from '../../components/ToolLayout.vue';
const cronExpression = ref('*/5 * * * *');
const presets = [
{ label: 'Every Minute', val: '* * * * *' },
{ label: 'Every 5 Minutes', val: '*/5 * * * *' },
{ label: 'Every Hour', val: '0 * * * *' },
{ label: 'Every Day at Midnight', val: '0 0 * * *' },
{ label: 'Every Week (Sun)', val: '0 0 * * 0' },
{ label: 'Every Month (1st)', val: '0 0 1 * *' },
];
const humanReadable = computed(() => {
try {
return cronstrue.toString(cronExpression.value);
} catch (e) {
return '';
}
});
</script>

View File

@@ -0,0 +1,92 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Diff Viewer</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Compare two text blocks and see differences side-by-side.</p>
</template>
<template #options>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Mode</label>
<select v-model="mode" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<option value="split">Split (Side-by-Side)</option>
<option value="unified">Unified (Inline)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Language</label>
<select v-model="language" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<option value="plaintext">Plain Text</option>
<option value="json">JSON</option>
<option value="javascript">JavaScript</option>
<option value="typescript">TypeScript</option>
<option value="python">Python</option>
<option value="html">HTML</option>
<option value="css">CSS</option>
</select>
</div>
</div>
</template>
<template #content>
<div class="h-full flex flex-col space-y-4">
<div class="grid grid-cols-2 gap-4 h-1/3 min-h-[150px]">
<div class="flex flex-col">
<label class="text-xs font-medium text-gray-500 mb-1">Original</label>
<textarea
v-model="prev"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
placeholder="Paste original text here..."
></textarea>
</div>
<div class="flex flex-col">
<label class="text-xs font-medium text-gray-500 mb-1">Modified</label>
<textarea
v-model="current"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
placeholder="Paste new text here..."
></textarea>
</div>
</div>
<div class="flex-1 border border-gray-200 dark:border-gray-700 rounded-md overflow-hidden bg-white dark:bg-gray-900">
<Diff
:mode="mode"
:theme="isDark ? 'dark' : 'light'"
:language="language"
:prev="prev"
:current="current"
class="h-full text-xs"
/>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import ToolLayout from '../../components/ToolLayout.vue';
const mode = ref('split');
const language = ref('plaintext');
const prev = ref('');
const current = ref('');
// Simple dark mode detection
const isDark = computed(() => window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches);
</script>
<style>
/* Vue Diff Overrides if needed */
.vue-diff-theme-light {
--diff-background: #ffffff;
--diff-text: #1f2937;
}
.vue-diff-theme-dark {
--diff-background: #111827;
--diff-text: #e5e7eb;
}
</style>

View File

@@ -0,0 +1,75 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Docker Converter</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert between 'docker run' commands and Docker Compose YAML.</p>
</template>
<template #content>
<div class="h-full flex flex-col space-y-4">
<!-- Docker Run Input -->
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Docker Run Command</label>
<button @click="convertToCompose" class="text-xs text-blue-600 hover:text-blue-500">Convert to Compose </button>
</div>
<textarea
v-model="dockerRun"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
placeholder="docker run -d -p 80:80 nginx"
></textarea>
</div>
<!-- Compose Output -->
<div class="flex-1 flex flex-col">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Docker Compose (YAML)</label>
<button @click="convertToRun" class="text-xs text-blue-600 hover:text-blue-500">Convert to Run </button>
</div>
<textarea
v-model="dockerCompose"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 font-mono text-xs resize-none"
placeholder="version: '3.3'&#10;services:..."
></textarea>
</div>
<div v-if="error" class="text-red-500 text-xs">{{ error }}</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import composerize from 'composerize';
import decomposerize from 'decomposerize';
import ToolLayout from '../../components/ToolLayout.vue';
const dockerRun = ref('');
const dockerCompose = ref('');
const error = ref('');
const convertToCompose = () => {
if (!dockerRun.value) return;
try {
dockerCompose.value = composerize(dockerRun.value);
error.value = '';
} catch (e: any) {
error.value = 'Error converting to Compose: ' + e.message;
}
};
const convertToRun = () => {
if (!dockerCompose.value) return;
try {
// Decomposerize types might be missing or different
const result = decomposerize(dockerCompose.value);
dockerRun.value = result;
error.value = '';
} catch (e: any) {
error.value = 'Error converting to Run: ' + e.message;
}
};
</script>

View File

@@ -0,0 +1,120 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">JSON / CSV / YAML Converter</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert data formats bi-directionally. Edit any field to update the others.</p>
</template>
<template #content>
<div class="h-full grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- JSON Input -->
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">JSON</label>
<span v-if="jsonError" class="text-xs text-red-500">Invalid JSON</span>
</div>
<textarea
v-model="json"
@input="updateFrom('json')"
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
placeholder='[{"id": 1, "name": "John"}]'
></textarea>
</div>
<!-- YAML Input -->
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">YAML</label>
<span v-if="yamlError" class="text-xs text-red-500">Invalid YAML</span>
</div>
<textarea
v-model="yaml"
@input="updateFrom('yaml')"
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-purple-500 focus:border-purple-500 font-mono text-xs resize-none"
placeholder="- id: 1&#10; name: John"
></textarea>
</div>
<!-- CSV Input -->
<div class="flex flex-col h-full">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">CSV</label>
<span v-if="csvError" class="text-xs text-red-500">Invalid CSV</span>
</div>
<textarea
v-model="csv"
@input="updateFrom('csv')"
class="flex-1 w-full h-full min-h-[400px] px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-green-500 focus:border-green-500 font-mono text-xs resize-none"
placeholder="id,name&#10;1,John"
></textarea>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import yamljs from 'js-yaml';
import Papa from 'papaparse';
import ToolLayout from '../../components/ToolLayout.vue';
const json = ref('');
const yaml = ref('');
const csv = ref('');
const jsonError = ref(false);
const yamlError = ref(false);
const csvError = ref(false);
const updateFrom = (source: 'json' | 'yaml' | 'csv') => {
let data: any = null;
// 1. Parse Source to Object
try {
if (source === 'json') {
data = JSON.parse(json.value);
jsonError.value = false;
} else if (source === 'yaml') {
data = yamljs.load(yaml.value);
yamlError.value = false;
} else if (source === 'csv') {
const result = Papa.parse(csv.value, { header: true, skipEmptyLines: true });
if (result.errors.length) throw new Error('CSV Error');
data = result.data;
csvError.value = false;
}
} catch (e) {
if (source === 'json') jsonError.value = true;
if (source === 'yaml') yamlError.value = true;
if (source === 'csv') csvError.value = true;
return; // Stop if invalid
}
// 2. Update Targets
if (source !== 'json') {
try {
json.value = JSON.stringify(data, null, 2);
jsonError.value = false;
} catch { json.value = ''; }
}
if (source !== 'yaml') {
try {
yaml.value = yamljs.dump(data);
yamlError.value = false;
} catch { yaml.value = ''; }
}
if (source !== 'csv') {
try {
// Papa unparse expects array of objects
const arrayData = Array.isArray(data) ? data : [data];
csv.value = Papa.unparse(arrayData);
csvError.value = false;
} catch { csv.value = ''; }
}
};
</script>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import { ref } from 'vue'
const input = ref('{"name":"Gemini","type":"AI"}')
const output = ref('')
const error = ref('')
const format = () => {
try {
const parsed = JSON.parse(input.value)
output.value = JSON.stringify(parsed, null, 2)
error.value = ''
} catch (e: any) {
error.value = e.message
}
}
const minify = () => {
try {
const parsed = JSON.parse(input.value)
output.value = JSON.stringify(parsed)
error.value = ''
} catch (e: any) {
error.value = e.message
}
}
</script>
<template>
<div class="h-[calc(100vh-200px)] flex flex-col">
<div class="flex space-x-2 mb-4">
<button @click="format" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Prettify</button>
<button @click="minify" class="px-4 py-2 bg-gray-200 text-gray-700 rounded hover:bg-gray-300">Minify</button>
</div>
<div class="flex-1 grid grid-cols-1 md:grid-cols-2 gap-4 min-h-0">
<div class="flex flex-col">
<label class="mb-2 text-sm font-bold text-gray-500">Input JSON</label>
<textarea
v-model="input"
class="flex-1 w-full p-4 font-mono text-sm border rounded-lg focus:ring-2 focus:ring-blue-500 resize-none"
:class="{'border-red-500': error}"
></textarea>
<p v-if="error" class="text-red-500 text-sm mt-1">{{ error }}</p>
</div>
<div class="flex flex-col">
<label class="mb-2 text-sm font-bold text-gray-500">Output</label>
<textarea
readonly
:value="output"
class="flex-1 w-full p-4 font-mono text-sm bg-gray-50 border rounded-lg resize-none text-gray-800"
></textarea>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,75 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">JWT Debugger</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Decode and inspect JSON Web Tokens (JWT) without backend verification.</p>
</template>
<template #content>
<div class="space-y-6">
<!-- Input Section -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Encoded Token
</label>
<textarea
v-model="token"
rows="3"
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
placeholder="Paste your JWT here (eyJhbGci...)"
></textarea>
</div>
<!-- Error Message -->
<div v-if="error" class="bg-red-50 dark:bg-red-900/30 text-red-600 dark:text-red-400 p-3 rounded-md text-sm">
{{ error }}
</div>
<!-- Output Section -->
<div v-if="decodedHeader || decodedPayload" class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- Header -->
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide">Header</h3>
<pre class="bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto text-xs font-mono h-64">{{ JSON.stringify(decodedHeader, null, 2) }}</pre>
</div>
<!-- Payload -->
<div>
<h3 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 uppercase tracking-wide">Payload</h3>
<pre class="bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto text-xs font-mono h-64">{{ JSON.stringify(decodedPayload, null, 2) }}</pre>
</div>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { jwtDecode } from 'jwt-decode';
import ToolLayout from '../../components/ToolLayout.vue';
const token = ref('');
const decodedHeader = ref<any>(null);
const decodedPayload = ref<any>(null);
const error = ref('');
watch(token, (newVal) => {
if (!newVal.trim()) {
decodedHeader.value = null;
decodedPayload.value = null;
error.value = '';
return;
}
try {
decodedHeader.value = jwtDecode(newVal, { header: true });
decodedPayload.value = jwtDecode(newVal);
error.value = '';
} catch (e) {
decodedHeader.value = null;
decodedPayload.value = null;
error.value = 'Invalid JWT format';
}
});
</script>

View File

@@ -0,0 +1,113 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Mock Data Generator</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate massive amounts of fake data for testing.</p>
</template>
<template #options>
<div class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Rows Count</label>
<div class="flex items-center space-x-2">
<input type="range" v-model.number="count" min="1" max="1000" class="flex-1">
<input type="number" v-model.number="count" class="w-20 rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm sm:text-sm">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Fields</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="id" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">ID (UUID)</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="firstName" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">First Name</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="lastName" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Last Name</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="email" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Email</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="phone" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Phone</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="address" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Address</span>
</label>
<label class="flex items-center">
<input type="checkbox" v-model="fields" value="jobTitle" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Job Title</span>
</label>
</div>
</div>
<button
@click="generate"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Regenerate
</button>
<button
@click="copyJson"
class="w-full flex justify-center py-2 px-4 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Copy JSON
</button>
</div>
</template>
<template #content>
<div class="h-full flex flex-col">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-gray-500">Preview ({{ count }} items)</span>
</div>
<textarea
readonly
:value="output"
class="flex-1 w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs resize-none"
></textarea>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue';
import { faker } from '@faker-js/faker';
import ToolLayout from '../../components/ToolLayout.vue';
const count = ref(10);
const output = ref('');
const fields = ref(['id', 'firstName', 'lastName', 'email']);
const generate = () => {
const data = Array.from({ length: count.value }, () => {
const item: any = {};
if (fields.value.includes('id')) item.id = faker.string.uuid();
if (fields.value.includes('firstName')) item.firstName = faker.person.firstName();
if (fields.value.includes('lastName')) item.lastName = faker.person.lastName();
if (fields.value.includes('email')) item.email = faker.internet.email();
if (fields.value.includes('phone')) item.phone = faker.phone.number();
if (fields.value.includes('address')) item.address = faker.location.streetAddress();
if (fields.value.includes('jobTitle')) item.jobTitle = faker.person.jobTitle();
return item;
});
output.value = JSON.stringify(data, null, 2);
};
const copyJson = async () => {
await navigator.clipboard.writeText(output.value);
};
onMounted(generate);
watch([count], generate); // Don't watch fields deeply to prevent too many re-renders
</script>

View File

@@ -0,0 +1,128 @@
<script setup lang="ts">
import { ref } from 'vue'
import { downloadCsv } from '../../utils/csvDownloader'
const count = ref(5)
const length = ref(16)
const includeUpper = ref(true)
const includeDigits = ref(true)
const includeSpecial = ref(true)
const results = ref<string[]>([])
// Secure random generator using Web Crypto API
const generate = () => {
const lower = 'abcdefghijklmnopqrstuvwxyz'
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
const digits = '0123456789'
const special = '!@#$%^&*()_+-=[]{}|;:,.<>?'
let chars = lower
if (includeUpper.value) chars += upper
if (includeDigits.value) chars += digits
if (includeSpecial.value) chars += special
if (!chars) {
alert("Please select at least one character set.")
return
}
const newPasswords: string[] = []
for (let i = 0; i < count.value; i++) {
let password = ''
const array = new Uint32Array(length.value)
crypto.getRandomValues(array)
for (let j = 0; j < length.value; j++) {
password += chars[array[j] % chars.length]
}
newPasswords.push(password)
}
results.value = newPasswords
}
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text)
}
const downloadResults = () => {
downloadCsv(results.value, `passwords-${new Date().getTime()}`, ['Password'])
}
</script>
<template>
<div class="max-w-5xl mx-auto">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<!-- Controls -->
<div class="p-6 border-b border-gray-100 bg-gray-50 grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Password Length: {{ length }}</label>
<input type="range" v-model.number="length" min="4" max="64" class="w-full accent-blue-600">
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-2">Quantity: {{ count }}</label>
<input type="range" v-model.number="count" min="1" max="50" class="w-full accent-blue-600">
</div>
</div>
<div class="space-y-3">
<label class="block text-xs font-bold text-gray-500 uppercase">Character Sets</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" v-model="includeUpper" class="rounded text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">Uppercase (A-Z)</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" v-model="includeDigits" class="rounded text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">Digits (0-9)</span>
</label>
<label class="flex items-center space-x-2 cursor-pointer">
<input type="checkbox" v-model="includeSpecial" class="rounded text-blue-600 focus:ring-blue-500">
<span class="text-sm text-gray-700">Special Characters (!@#$...)</span>
</label>
</div>
</div>
<!-- Actions -->
<div class="p-4 bg-gray-50 border-b border-gray-100 flex justify-end space-x-3">
<button
v-if="results.length > 0"
@click="downloadResults"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded-lg font-medium transition flex items-center shadow-sm"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download CSV
</button>
<button
@click="generate"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-sm transition flex items-center"
>
Generate
</button>
</div>
<!-- Results -->
<div class="p-0">
<ul v-if="results.length > 0" class="divide-y divide-gray-100">
<li v-for="(pwd, idx) in results" :key="idx" class="p-4 font-mono text-gray-700 hover:bg-gray-50 flex justify-between items-center group">
<span class="break-all mr-4">{{ pwd }}</span>
<button
@click="copyToClipboard(pwd)"
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-600 transition"
title="Copy"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
</button>
</li>
</ul>
<div v-else class="p-12 text-center text-gray-400">
Adjust settings and click generate to create secure passwords locally.
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,233 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
interface CompressionResult {
filename: string
original_size: number
compressed_size: number
ratio: number
status: string
}
const files = ref<File[]>([])
const isDragging = ref(false)
const uploading = ref(false)
const results = ref<CompressionResult[]>([])
const downloadUrl = ref('')
const downloadFilename = ref('')
const jobId = ref('')
const totalSavings = computed(() => {
if (results.value.length === 0) return 0
const orig = results.value.reduce((acc, r) => acc + r.original_size, 0)
const comp = results.value.reduce((acc, r) => acc + r.compressed_size, 0)
return Math.round(((orig - comp) / orig) * 100)
})
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const onDrop = (e: DragEvent) => {
isDragging.value = false
const droppedFiles = e.dataTransfer?.files
if (droppedFiles) {
addFiles(droppedFiles)
}
}
const onFileSelect = (e: Event) => {
const target = e.target as HTMLInputElement
if (target.files) {
addFiles(target.files)
}
}
const addFiles = (fileList: FileList) => {
// Reset previous results if adding new files
if (results.value.length > 0) {
results.value = []
downloadUrl.value = ''
files.value = []
jobId.value = ''
}
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
if (file.type === 'image/png') {
files.value.push(file)
} else {
alert(`Skipped non-PNG file: ${file.name}`)
}
}
}
const removeFile = (index: number) => {
files.value.splice(index, 1)
}
const processFiles = async () => {
if (files.value.length === 0) return
uploading.value = true
const formData = new FormData()
files.value.forEach(file => {
formData.append('files', file)
})
try {
const res = await fetch('/api/tools/png-compress', {
method: 'POST',
body: formData
})
if (!res.ok) throw new Error('Compression failed')
const data = await res.json()
results.value = data.stats
jobId.value = data.job_id
downloadFilename.value = data.zip_filename
downloadUrl.value = `/api/tools/download/${data.zip_filename}`
} catch (e) {
console.error(e)
alert('Failed to compress images.')
} finally {
uploading.value = false
}
}
</script>
<template>
<div class="max-w-6xl mx-auto">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="p-6 border-b border-gray-100">
<h2 class="text-xl font-bold text-gray-800">PNG Compressor</h2>
<p class="text-gray-500 text-sm mt-1">
Compress PNGs using <code>pngquant</code>. Reduces file size by 60-80% with minimal quality loss.
</p>
</div>
<div class="p-6">
<!-- Drag & Drop Area -->
<div
v-if="results.length === 0"
@dragover.prevent="isDragging = true"
@dragleave.prevent="isDragging = false"
@drop.prevent="onDrop"
class="border-2 border-dashed rounded-xl p-8 text-center transition-colors duration-200 cursor-pointer"
:class="isDragging ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'"
@click="$refs.fileInput.click()"
>
<input
ref="fileInput"
type="file"
multiple
accept="image/png"
class="hidden"
@change="onFileSelect"
>
<div class="flex flex-col items-center justify-center space-y-3">
<svg class="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path></svg>
<div class="text-gray-600">
<span class="font-medium text-blue-600 hover:text-blue-500">Click to upload</span>
or drag and drop
</div>
<p class="text-xs text-gray-400">PNG files only</p>
</div>
</div>
<!-- File List (Pending) -->
<div v-if="files.length > 0 && results.length === 0" class="mt-6">
<h3 class="text-sm font-bold text-gray-500 uppercase mb-3">Selected Files ({{ files.length }})</h3>
<ul class="divide-y divide-gray-100 border rounded-lg overflow-hidden">
<li v-for="(file, idx) in files" :key="idx" class="p-3 bg-gray-50 flex justify-between items-center text-sm">
<span class="truncate">{{ file.name }}</span>
<div class="flex items-center space-x-4">
<span class="text-gray-400">{{ formatBytes(file.size) }}</span>
<button @click.stop="removeFile(idx)" class="text-red-400 hover:text-red-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
</button>
</div>
</li>
</ul>
<div class="mt-6 flex justify-end">
<button
@click="processFiles"
:disabled="uploading"
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg font-bold shadow-sm transition disabled:opacity-50 flex items-center"
>
<svg v-if="uploading" class="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
{{ uploading ? 'Compressing...' : 'Upload & Process' }}
</button>
</div>
</div>
<!-- Results -->
<div v-if="results.length > 0" class="mt-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-gray-800">Results</h3>
<span class="text-green-600 font-bold bg-green-50 px-3 py-1 rounded-full text-sm">
Total Savings: {{ totalSavings }}%
</span>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-left text-sm whitespace-nowrap">
<thead class="uppercase tracking-wider border-b-2 border-gray-100 bg-gray-50 text-gray-500 font-semibold">
<tr>
<th scope="col" class="px-4 py-3">Filename</th>
<th scope="col" class="px-4 py-3 text-right">Original</th>
<th scope="col" class="px-4 py-3 text-right">Compressed</th>
<th scope="col" class="px-4 py-3 text-right">Ratio</th>
<th scope="col" class="px-4 py-3 text-center">Status</th>
<th scope="col" class="px-4 py-3 text-center">Action</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="(res, idx) in results" :key="idx" class="hover:bg-gray-50">
<td class="px-4 py-3 font-medium text-gray-900">{{ res.filename }}</td>
<td class="px-4 py-3 text-right text-gray-500">{{ formatBytes(res.original_size) }}</td>
<td class="px-4 py-3 text-right text-gray-900 font-bold">{{ formatBytes(res.compressed_size) }}</td>
<td class="px-4 py-3 text-right text-green-600">{{ res.ratio }}%</td>
<td class="px-4 py-3 text-center">
<span v-if="res.status === 'Success'" class="text-green-600"></span>
<span v-else class="text-gray-400 text-xs">{{ res.status }}</span>
</td>
<td class="px-4 py-3 text-center">
<a
v-if="res.status === 'Success'"
:href="`/api/tools/download-single/${jobId}/${res.filename}`"
target="_blank"
class="text-blue-600 hover:text-blue-800 font-medium text-xs border border-blue-200 bg-blue-50 px-2 py-1 rounded hover:bg-blue-100 transition"
>
Download
</a>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-8 flex justify-between items-center bg-gray-50 p-4 rounded-lg">
<button @click="files = []; results = []; jobId = ''" class="text-gray-500 hover:text-gray-700 font-medium text-sm">
&larr; Compress More
</button>
<a
:href="downloadUrl"
class="px-6 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg font-bold shadow-lg shadow-green-200 transition flex items-center"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
Download All (ZIP)
</a>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,109 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">QR Code Generator</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Generate QR codes instantly from text or URLs.</p>
</template>
<template #options>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Text / URL</label>
<textarea
v-model="text"
rows="3"
class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm"
placeholder="https://example.com"
></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Error Correction</label>
<select v-model="errorLevel" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<option value="L">Low (7%)</option>
<option value="M">Medium (15%)</option>
<option value="Q">Quartile (25%)</option>
<option value="H">High (30%)</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Scale (Size)</label>
<input
type="range"
v-model.number="scale"
min="2"
max="10"
class="w-full"
/>
<div class="text-xs text-gray-500 text-right">{{ scale }}x</div>
</div>
<button
v-if="qrDataUrl"
@click="downloadQr"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Download PNG
</button>
</div>
</template>
<template #content>
<div class="h-full flex items-center justify-center bg-gray-100 dark:bg-black/20 rounded-lg border-2 border-dashed border-gray-200 dark:border-gray-700">
<div v-if="text" class="text-center">
<img :src="qrDataUrl" alt="QR Code" class="mx-auto shadow-lg border-4 border-white" />
<p class="mt-4 text-sm text-gray-500 break-all max-w-md">{{ text }}</p>
</div>
<div v-else class="text-center text-gray-400">
<p>Enter text to generate QR code</p>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import QRCode from 'qrcode';
import ToolLayout from '../../components/ToolLayout.vue';
const text = ref('https://example.com');
const errorLevel = ref('M');
const scale = ref(6);
const qrDataUrl = ref('');
const generate = async () => {
if (!text.value) {
qrDataUrl.value = '';
return;
}
try {
qrDataUrl.value = await QRCode.toDataURL(text.value, {
errorCorrectionLevel: errorLevel.value as any,
width: undefined,
scale: scale.value,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff'
}
});
} catch (err) {
console.error(err);
}
};
const downloadQr = () => {
const link = document.createElement('a');
link.download = 'qrcode.png';
link.href = qrDataUrl.value;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
watch([text, errorLevel, scale], generate);
onMounted(generate);
</script>

View File

@@ -0,0 +1,98 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Regex Tester</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Test and debug regular expressions.</p>
</template>
<template #options>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Pattern</label>
<input
v-model="pattern"
type="text"
class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm font-mono"
placeholder="[a-z]+"
/>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Flags</label>
<div class="flex space-x-2">
<label class="inline-flex items-center text-xs">
<input type="checkbox" v-model="flagGlobal" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-1 text-gray-700 dark:text-gray-300">g</span>
</label>
<label class="inline-flex items-center text-xs">
<input type="checkbox" v-model="flagInsensitive" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-1 text-gray-700 dark:text-gray-300">i</span>
</label>
<label class="inline-flex items-center text-xs">
<input type="checkbox" v-model="flagMultiline" class="rounded border-gray-300 text-blue-600 shadow-sm focus:border-blue-300 focus:ring focus:ring-blue-200 focus:ring-opacity-50">
<span class="ml-1 text-gray-700 dark:text-gray-300">m</span>
</label>
</div>
</div>
<div v-if="error" class="text-xs text-red-500">{{ error }}</div>
</div>
</template>
<template #content>
<div class="h-full flex flex-col space-y-4">
<div class="flex-1 flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Test String</label>
<textarea
v-model="text"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-none"
placeholder="Type text here to test against the regex..."
></textarea>
</div>
<div class="h-1/3 bg-gray-50 dark:bg-gray-900 p-4 rounded-md border border-gray-200 dark:border-gray-700 overflow-auto">
<h3 class="text-xs font-medium text-gray-500 mb-2 uppercase">Matches</h3>
<div v-if="matches.length === 0" class="text-sm text-gray-400">No matches found.</div>
<ul v-else class="space-y-1">
<li v-for="(match, idx) in matches" :key="idx" class="text-sm font-mono text-gray-800 dark:text-gray-200">
<span class="bg-blue-100 dark:bg-blue-900/50 px-1 rounded">Match {{ idx + 1 }}:</span> {{ match }}
</li>
</ul>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import ToolLayout from '../../components/ToolLayout.vue';
const pattern = ref('');
const text = ref('');
const flagGlobal = ref(true);
const flagInsensitive = ref(false);
const flagMultiline = ref(false);
const error = ref('');
const matches = computed(() => {
if (!pattern.value) return [];
try {
let flags = '';
if (flagGlobal.value) flags += 'g';
if (flagInsensitive.value) flags += 'i';
if (flagMultiline.value) flags += 'm';
const regex = new RegExp(pattern.value, flags);
error.value = '';
if (!text.value) return [];
const found = text.value.match(regex);
return found ? Array.from(found) : [];
} catch (e: any) {
error.value = e.message;
return [];
}
});
</script>

View File

@@ -0,0 +1,91 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">SQL Formatter</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Beautify and standardize your SQL queries.</p>
</template>
<template #options>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Language</label>
<select v-model="language" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<option value="sql">Standard SQL</option>
<option value="mysql">MySQL</option>
<option value="postgresql">PostgreSQL</option>
<option value="plsql">PL/SQL</option>
<option value="tsql">Transact-SQL</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Indent Style</label>
<select v-model="indent" class="w-full rounded-md border-gray-300 dark:border-gray-700 dark:bg-gray-900 shadow-sm focus:border-blue-500 focus:ring-blue-500 sm:text-sm">
<option value=" ">2 Spaces</option>
<option value=" ">4 Spaces</option>
<option value="\t">Tab</option>
</select>
</div>
<button
@click="formatSql"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Format SQL
</button>
</div>
</template>
<template #content>
<div class="h-full flex flex-col space-y-4">
<div class="flex-1 flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Input</label>
<textarea
v-model="inputSql"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-sm resize-none"
placeholder="SELECT * FROM table WHERE id = 1"
></textarea>
</div>
<div class="flex-1 flex flex-col">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Output</label>
<div class="relative flex-1">
<textarea
readonly
:value="outputSql"
class="absolute inset-0 w-full h-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-sm resize-none"
></textarea>
</div>
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { format } from 'sql-formatter';
import ToolLayout from '../../components/ToolLayout.vue';
const inputSql = ref('');
const outputSql = ref('');
const language = ref('sql');
const indent = ref(' ');
const formatSql = () => {
if (!inputSql.value) {
outputSql.value = '';
return;
}
try {
outputSql.value = format(inputSql.value, {
language: language.value as any,
tabWidth: indent.value === '\t' ? undefined : indent.value.length,
useTabs: indent.value === '\t',
});
} catch (e: any) {
outputSql.value = `Error: ${e.message}`;
}
};
</script>

View File

@@ -0,0 +1,83 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">SVG Optimizer</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Minify SVG code using SVGO.</p>
</template>
<template #content>
<div class="h-full flex flex-col space-y-4">
<div class="grid grid-cols-2 gap-4 h-full">
<!-- Input -->
<div class="flex flex-col">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Input SVG</label>
<span class="text-xs text-gray-500">{{ inputSize }} bytes</span>
</div>
<textarea
v-model="inputSvg"
class="flex-1 w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500 font-mono text-xs resize-none"
placeholder="<svg>...</svg>"
></textarea>
</div>
<!-- Output -->
<div class="flex flex-col">
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Optimized SVG</label>
<span class="text-xs text-green-600 font-medium" v-if="saved > 0">-{{ saved }}% ({{ outputSize }} bytes)</span>
</div>
<textarea
readonly
:value="outputSvg"
class="flex-1 w-full px-3 py-2 bg-gray-50 dark:bg-gray-900 border border-gray-300 dark:border-gray-700 rounded-md shadow-sm font-mono text-xs resize-none"
></textarea>
</div>
</div>
<button
@click="optimize"
class="w-full flex justify-center py-3 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Optimize SVG
</button>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { optimize as svgoOptimize } from 'svgo/browser';
import ToolLayout from '../../components/ToolLayout.vue';
const inputSvg = ref('');
const outputSvg = ref('');
const inputSize = computed(() => new Blob([inputSvg.value]).size);
const outputSize = computed(() => new Blob([outputSvg.value]).size);
const saved = computed(() => {
if (inputSize.value === 0) return 0;
return Math.round(((inputSize.value - outputSize.value) / inputSize.value) * 100);
});
const optimize = () => {
if (!inputSvg.value) return;
try {
const result = svgoOptimize(inputSvg.value, {
multipass: true,
plugins: [
'preset-default',
'removeDimensions',
'convertStyleToAttrs',
],
});
outputSvg.value = result.data;
} catch (e) {
console.error(e);
outputSvg.value = 'Error optimizing SVG';
}
};
</script>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
const input = ref('https://example.com/search?q=hello world')
const mode = ref<'encode' | 'decode'>('encode')
const output = computed(() => {
if (!input.value) return ''
try {
return mode.value === 'encode'
? encodeURIComponent(input.value)
: decodeURIComponent(input.value)
} catch (e) {
return 'Error: Invalid URI'
}
})
const copyToClipboard = () => {
navigator.clipboard.writeText(output.value)
alert('Copied!')
}
</script>
<template>
<div class="max-w-6xl mx-auto">
<div class="bg-white rounded-xl shadow-sm p-6 border border-gray-200">
<div class="flex items-center space-x-4 mb-6">
<button
@click="mode = 'encode'"
class="px-4 py-2 rounded-lg font-medium transition"
:class="mode === 'encode' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
Encode
</button>
<button
@click="mode = 'decode'"
class="px-4 py-2 rounded-lg font-medium transition"
:class="mode === 'decode' ? 'bg-blue-600 text-white shadow-lg shadow-blue-200' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
>
Decode
</button>
</div>
<div class="grid gap-6">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Input</label>
<textarea
v-model="input"
rows="4"
class="w-full rounded-lg border-gray-300 border p-3 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 font-mono text-sm"
></textarea>
</div>
<div class="flex justify-center">
<svg class="w-6 h-6 text-gray-400 rotate-90 md:rotate-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 14l-7 7m0 0l-7-7m7 7V3"></path></svg>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">Output</label>
<div class="relative">
<textarea
readonly
:value="output"
rows="4"
class="w-full rounded-lg border-gray-300 border p-3 bg-gray-50 font-mono text-sm text-gray-800"
></textarea>
<button
@click="copyToClipboard"
class="absolute top-2 right-2 p-2 bg-white rounded border border-gray-200 hover:bg-gray-50 text-gray-500"
title="Copy"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path></svg>
</button>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,93 @@
<script setup lang="ts">
import { ref } from 'vue'
import { v1 as uuidv1, v4 as uuidv4 } from 'uuid'
import { downloadCsv } from '../../utils/csvDownloader'
const count = ref(5)
const version = ref(4)
const results = ref<string[]>([])
const generate = () => {
const newUuids: string[] = []
for (let i = 0; i < count.value; i++) {
if (version.value === 1) {
newUuids.push(uuidv1())
} else {
// Use native crypto API if available for v4, fallback to library
if (typeof crypto.randomUUID === 'function') {
newUuids.push(crypto.randomUUID())
} else {
newUuids.push(uuidv4())
}
}
}
results.value = newUuids
}
const downloadResults = () => {
downloadCsv(results.value, `uuids-v${version.value}-${new Date().getTime()}`, ['UUID'])
}
</script>
<template>
<div class="max-w-5xl mx-auto">
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div class="p-6 border-b border-gray-100 bg-gray-50">
<div class="flex flex-wrap items-end gap-4">
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Quantity</label>
<input
v-model.number="count"
type="number"
min="1"
max="50"
class="w-24 rounded border-gray-300 p-2"
>
</div>
<div>
<label class="block text-xs font-bold text-gray-500 uppercase mb-1">Version</label>
<select v-model.number="version" class="w-32 rounded border-gray-300 p-2">
<option :value="4">UUID v4 (Random)</option>
<option :value="1">UUID v1 (Time)</option>
</select>
</div>
<div class="ml-auto flex space-x-2">
<button
v-if="results.length > 0"
@click="downloadResults"
class="px-4 py-2 bg-white border border-gray-300 text-gray-700 hover:bg-gray-50 rounded font-medium transition flex items-center"
>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path></svg>
CSV
</button>
<button
@click="generate"
class="px-6 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded font-medium transition"
>
Generate UUIDs
</button>
</div>
</div>
</div>
<div class="p-0">
<ul v-if="results.length > 0" class="divide-y divide-gray-100">
<li v-for="(uuid, idx) in results" :key="idx" class="p-4 font-mono text-gray-700 hover:bg-gray-50 flex justify-between items-center group">
<span>{{ uuid }}</span>
<button
@click="navigator.clipboard.writeText(uuid)"
class="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-blue-600 transition"
>
Copy
</button>
</li>
</ul>
<div v-else class="p-12 text-center text-gray-400">
Press generate to create UUIDs using Python backend.
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,111 @@
<template>
<ToolLayout>
<template #header>
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">Video to GIF</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Convert short video clips to GIF format (Server-side).</p>
</template>
<template #content>
<div class="max-w-4xl mx-auto space-y-8">
<!-- Upload -->
<div
class="border-2 border-dashed border-gray-300 dark:border-gray-700 rounded-lg p-12 text-center hover:border-blue-500 dark:hover:border-blue-500 transition-colors cursor-pointer"
@click="triggerFileInput"
@drop.prevent="handleDrop"
@dragover.prevent
>
<div v-if="loading" class="space-y-4">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-blue-600 mx-auto"></div>
<p class="text-gray-600 dark:text-gray-300">Converting... This may take a moment.</p>
</div>
<div v-else class="space-y-2">
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48">
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
<div class="text-gray-600 dark:text-gray-300">
<span class="font-medium text-blue-600 hover:text-blue-500">Upload Video</span> (MP4, MOV)
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">Max 10MB recommended</p>
</div>
<input ref="fileInput" type="file" accept="video/*" class="hidden" @change="handleFileSelect" />
</div>
<!-- Result -->
<div v-if="gifUrl" class="text-center space-y-4">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Conversion Complete!</h3>
<img :src="gifUrl" alt="Converted GIF" class="mx-auto rounded shadow-lg max-h-96" />
<a
:href="gifUrl"
download="converted.gif"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
Download GIF
</a>
</div>
<div v-if="error" class="bg-red-50 dark:bg-red-900/20 p-4 rounded text-red-600 dark:text-red-400 text-sm text-center">
{{ error }}
</div>
</div>
</template>
</ToolLayout>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import axios from 'axios';
import ToolLayout from '../../components/ToolLayout.vue';
const fileInput = ref<HTMLInputElement | null>(null);
const loading = ref(false);
const gifUrl = ref('');
const error = ref('');
const triggerFileInput = () => {
if (!loading.value) fileInput.value?.click();
};
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) {
uploadAndConvert(target.files[0]);
}
};
const handleDrop = (event: DragEvent) => {
if (loading.value) return;
if (event.dataTransfer?.files && event.dataTransfer.files[0]) {
uploadAndConvert(event.dataTransfer.files[0]);
}
};
const uploadAndConvert = async (file: File) => {
if (!file.type.startsWith('video/')) {
error.value = 'Please upload a valid video file.';
return;
}
loading.value = true;
error.value = '';
gifUrl.value = '';
const formData = new FormData();
formData.append('file', file);
try {
const response = await axios.post('/api/tools/video/gif', formData, {
responseType: 'blob'
});
const blob = new Blob([response.data], { type: 'image/gif' });
gifUrl.value = URL.createObjectURL(blob);
} catch (e: any) {
error.value = 'Conversion failed. The server might be busy or the format is unsupported.';
console.error(e);
} finally {
loading.value = false;
}
};
</script>

22
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
proxy: {
// 개발 중 API 요청을 백엔드(FastAPI)로 프록시
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})