develop-updateui #1
231
bun.lock
231
bun.lock
@@ -11,34 +11,31 @@
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^0.21.7",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.12.5",
|
||||
"i18next": "^25.8.14",
|
||||
"hono": "^4.12.7",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.29",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.26.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.5",
|
||||
"unocss": "^66.6.6",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.70.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -101,24 +98,6 @@
|
||||
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.14.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260218.0" }, "optionalPeers": ["workerd"] }, "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg=="],
|
||||
|
||||
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.26.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.14.0", "miniflare": "4.20260301.1", "unenv": "2.0.0-rc.24", "wrangler": "4.70.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-F5jSOj9JeWMp9iQa2x+Ocjz++SCfK6Phcca/YLkaddPw5ie7W1VvEWudQ/gxYtRd47mQ/PfCLkE9QGyy6OGEng=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260301.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260301.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260301.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260301.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260301.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
@@ -201,56 +180,6 @@
|
||||
|
||||
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@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/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -311,16 +240,10 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||
|
||||
"@pinia/colada": ["@pinia/colada@0.21.7", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-b8dJgRSjh7o6NnPXuvMbqv6JhoD/m/CwdadKl5SQvygsbUveYCBoqtnWzPch8AEW/UK0I3rFoATE8WrfI2cgKA=="],
|
||||
"@pinia/colada": ["@pinia/colada@1.0.0", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-YKSybA6wusFK4CAUPzItoSgPCfScVnnnO2MSlmaaisE/L7luE77GxFyhTzipM8IbvbXh4zkCy97OE7w9WX34wA=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
@@ -371,10 +294,6 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
@@ -385,49 +304,49 @@
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.1.10", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.10" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-VP78Onh2HNezLPfhYjfHqn4dxlcQsE6PJgTTs61NksO/thvilNswtgBq0N0MWCLtn43N5akEPGW2y2zxM3PWgQ=="],
|
||||
"@unhead/vue": ["@unhead/vue@2.1.12", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.12" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g=="],
|
||||
|
||||
"@unocss/cli": ["@unocss/cli@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-directives": "66.6.5", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-UlETATpAZ+A5gOfj+z+BMXuIUcXCMjvlQteQE0VR2Yf0VIxz4sVO4z0VCXwXsxLTMfQiIMDpKVrGeczcYicvTA=="],
|
||||
"@unocss/cli": ["@unocss/cli@66.6.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.6", "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6", "@unocss/preset-wind4": "66.6.6", "@unocss/transformer-directives": "66.6.6", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg=="],
|
||||
|
||||
"@unocss/config": ["@unocss/config@66.6.4", "", { "dependencies": { "@unocss/core": "66.6.4", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-iwHl5FG81cOAMalqigjw21Z2tMa0xjN0doQxnGOLx8KP+BllruXSjBj8CRk3m6Ny9fDxfpFY0ruYbIBA5AGwDQ=="],
|
||||
"@unocss/config": ["@unocss/config@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g=="],
|
||||
|
||||
"@unocss/core": ["@unocss/core@66.6.5", "", {}, "sha512-hzjo+0EF+pNbf+tb0OjRNZRF9BJoKECcZZgtufxRPpWJdlv+aYmNkH1p9fldlHHzYcn3ZqVnnHnmk7HwaolJbg=="],
|
||||
"@unocss/core": ["@unocss/core@66.6.6", "", {}, "sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg=="],
|
||||
|
||||
"@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-wqzRtbyy3I595WCwwb8VBmznJTHWcTdylzVT+WBgacJDjRlT1sXaq2fRlOsHvtTRj1qG70t3PwKc6XgU0hutNg=="],
|
||||
"@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g=="],
|
||||
|
||||
"@unocss/inspector": ["@unocss/inspector@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-rrXPlSeRfYajEL65FL1Ok9Hfhjy9zvuZZwqXh9P0qCJlou2r2IqDFO/Gf9j5yO89tnKIfJ8ff6jEyqUmzbKSMQ=="],
|
||||
"@unocss/inspector": ["@unocss/inspector@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q=="],
|
||||
|
||||
"@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-fx+pKMZ0WgT+dfinVaLkNXlx6oZFwtMbZj5O/1SQia0UcfhnyS+G35HYpbgoc9GEAl3DclxxotzZjveZm++9fA=="],
|
||||
"@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ=="],
|
||||
|
||||
"@unocss/preset-icons": ["@unocss/preset-icons@66.6.5", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-03ppAcTWD77w1WZhORT8c9beTHBtWu3cx+c4qfShOfY6LQmZgx5i7DhCij5Wcj/U1zYA4Vrh13CDEmpsdZO3Cw=="],
|
||||
"@unocss/preset-icons": ["@unocss/preset-icons@66.6.6", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.6", "ofetch": "^1.5.1" } }, "sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA=="],
|
||||
|
||||
"@unocss/preset-mini": ["@unocss/preset-mini@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Ber3k2jlE8JP0y507hw/lvdDvcxfY0t4zaGA7hVZdEqlH6Eus/TqIVZ9tdMH4u0VDWYeAs98YV+auUJmMqGXpg=="],
|
||||
"@unocss/preset-mini": ["@unocss/preset-mini@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/extractor-arbitrary-variants": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w=="],
|
||||
|
||||
"@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-YYk/eg1OWX4Nx7rK1YZLMHXXntzNRDHp6BIInJteQmlXw0sFgrtdMKj7fnxrORsBDHwxWMp4sWEucPvfCtTlVQ=="],
|
||||
"@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg=="],
|
||||
|
||||
"@unocss/preset-typography": ["@unocss/preset-typography@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Cb63tdC0P2rgj/4t4DrSCl6RHebNpjUp9FQArg0KCnFnW75nWtKlsKpHuEXpi7KwrgOIx+rjlkwC1bDcsdNLHw=="],
|
||||
"@unocss/preset-typography": ["@unocss/preset-typography@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA=="],
|
||||
|
||||
"@unocss/preset-uno": ["@unocss/preset-uno@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-feZfGyzt3dH4h6yP2kjsx5MuoI1gU7vY/VL5O+ObosaB7HzzOFCsu2WzlvWn/FTRBi+scvdq436hsfflVyHYfQ=="],
|
||||
"@unocss/preset-uno": ["@unocss/preset-uno@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6" } }, "sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw=="],
|
||||
|
||||
"@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-u5jEHYTMeseykqinXd2VY2n7q9yFQlZotREpfSAft8ENNJdV7Yg/6It3lL68zT/k1AV/A8gk94KEuDh0fnoSxQ=="],
|
||||
"@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "ofetch": "^1.5.1" } }, "sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A=="],
|
||||
|
||||
"@unocss/preset-wind": ["@unocss/preset-wind@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-GLu7LzVF0LHqdZoHFZ8dbsCv8TD5ZH/r10CQbrL5qwmp4a/uyfDEmsre4Nsqim7JktRyXn3HK2XQmTB8AmXpgQ=="],
|
||||
"@unocss/preset-wind": ["@unocss/preset-wind@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6" } }, "sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw=="],
|
||||
|
||||
"@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-0ccQoJmHq4tTnn5C0UKhP598B/gG65AjqlfgfRpwt059yAWYqizGy6MRUGdLklyEK4H06E6qbMBqIjla2rOexQ=="],
|
||||
"@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-mini": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ=="],
|
||||
|
||||
"@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-JT57CU60PY3/PHBvxY+UG53I9K+awin/TodZTn4lqQNnF2v6fjkeBKiys9cxeoP4wbHuQWorrW4GqRLNDWIMcw=="],
|
||||
"@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/extractor-arbitrary-variants": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw=="],
|
||||
|
||||
"@unocss/rule-utils": ["@unocss/rule-utils@66.6.5", "", { "dependencies": { "@unocss/core": "^66.6.5", "magic-string": "^0.30.21" } }, "sha512-eDGXoMebb5aeEAFa2y4gnGLC+CHZPx93JYCt6uvEyf9xOoetwDcZaYC8brWdjaSKn+WVgsfxiZreC7F0rJywOQ=="],
|
||||
"@unocss/rule-utils": ["@unocss/rule-utils@66.6.6", "", { "dependencies": { "@unocss/core": "^66.6.6", "magic-string": "^0.30.21" } }, "sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw=="],
|
||||
|
||||
"@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "oxc-parser": "^0.115.0", "oxc-walker": "^0.7.0" } }, "sha512-/dVaRR7V/2Alskb2rUPmP/lhyb/YCxYyYNxp30kxxW0ew6mZWXQRzsxOJJVmGp23Uw7HxUW63t8zXzUdoI0b+g=="],
|
||||
"@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "oxc-parser": "^0.115.0", "oxc-walker": "^0.7.0" } }, "sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA=="],
|
||||
|
||||
"@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-U/ukk5lyZOFNyz9hVzZBkxciayjgimyfPuQBa5PHSC4W3nDmnFd1zgXzUVaM6KduPmiTExzpJSDgELb2OTbpqg=="],
|
||||
"@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw=="],
|
||||
|
||||
"@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "css-tree": "^3.1.0" } }, "sha512-QgofDdDedNK6dQ246+RXhM6gTzRz7NuetQQ8UnNgArm4PBHngVrrkjCzG1ByDTtEtoE8WR70UMR4Vf5dXTcHPw=="],
|
||||
"@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6", "css-tree": "^3.1.0" } }, "sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg=="],
|
||||
|
||||
"@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-k6vQgn/P7ObHBRYw6o1+xwdQIfwc6b9O5TFFe87UmBB6hJ2zaHWRVuPB6oky7F9Gz8bPfXC3WJuv7UyIwRmBQQ=="],
|
||||
"@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA=="],
|
||||
|
||||
"@unocss/vite": ["@unocss/vite@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/inspector": "66.6.5", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-J/QZa6h94ordZlZytIKQkuYa+G2GiWiS3y9O1uoHAAN2tzFSkgCXNUif7lHu1h4eCrgC0AOHJSYWg1LIASNDkg=="],
|
||||
"@unocss/vite": ["@unocss/vite@66.6.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.6", "@unocss/core": "66.6.6", "@unocss/inspector": "66.6.6", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
|
||||
@@ -441,13 +360,13 @@
|
||||
|
||||
"@vue/babel-plugin-resolve-type": ["@vue/babel-plugin-resolve-type@2.0.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/parser": "^7.28.4", "@vue/compiler-sfc": "^3.5.22" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
@@ -455,15 +374,15 @@
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.29", "", { "dependencies": { "@vue/shared": "3.5.29" } }, "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA=="],
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg=="],
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg=="],
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.29", "", { "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "vue": "3.5.29" } }, "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g=="],
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
|
||||
|
||||
@@ -491,8 +410,6 @@
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
@@ -521,8 +438,6 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"cross-fetch": ["cross-fetch@4.0.0", "", { "dependencies": { "node-fetch": "^2.6.12" } }, "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g=="],
|
||||
@@ -547,8 +462,6 @@
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
@@ -573,11 +486,11 @@
|
||||
|
||||
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
|
||||
|
||||
"hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
|
||||
"hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
|
||||
|
||||
"hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
|
||||
|
||||
"i18next": ["i18next@25.8.14", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="],
|
||||
"i18next": ["i18next@25.8.18", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA=="],
|
||||
|
||||
"i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="],
|
||||
|
||||
@@ -597,8 +510,6 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
@@ -643,8 +554,6 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
@@ -673,8 +582,6 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
@@ -707,8 +614,6 @@
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -725,8 +630,6 @@
|
||||
|
||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
@@ -747,17 +650,13 @@
|
||||
|
||||
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
|
||||
|
||||
"undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"unhead": ["unhead@2.1.10", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g=="],
|
||||
"unhead": ["unhead@2.1.12", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA=="],
|
||||
|
||||
"unimport": ["unimport@5.6.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "pkg-types": "^2.3.0", "scule": "^1.3.0", "strip-literal": "^3.1.0", "tinyglobby": "^0.2.15", "unplugin": "^2.3.11", "unplugin-utils": "^0.3.1" } }, "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A=="],
|
||||
|
||||
"unocss": ["unocss@66.6.5", "", { "dependencies": { "@unocss/cli": "66.6.5", "@unocss/core": "66.6.5", "@unocss/preset-attributify": "66.6.5", "@unocss/preset-icons": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/preset-tagify": "66.6.5", "@unocss/preset-typography": "66.6.5", "@unocss/preset-uno": "66.6.5", "@unocss/preset-web-fonts": "66.6.5", "@unocss/preset-wind": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-attributify-jsx": "66.6.5", "@unocss/transformer-compile-class": "66.6.5", "@unocss/transformer-directives": "66.6.5", "@unocss/transformer-variant-group": "66.6.5", "@unocss/vite": "66.6.5" }, "peerDependencies": { "@unocss/astro": "66.6.5", "@unocss/postcss": "66.6.5", "@unocss/webpack": "66.6.5" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-WlpPlV7yAzEPREcwaKeacP+1jOm6ImhyKJRkK18tIW2b2BRZZDKln7X8P+NzJtAr0kziNY/ttUKZNZRnSmzP1A=="],
|
||||
"unocss": ["unocss@66.6.6", "", { "dependencies": { "@unocss/cli": "66.6.6", "@unocss/core": "66.6.6", "@unocss/preset-attributify": "66.6.6", "@unocss/preset-icons": "66.6.6", "@unocss/preset-mini": "66.6.6", "@unocss/preset-tagify": "66.6.6", "@unocss/preset-typography": "66.6.6", "@unocss/preset-uno": "66.6.6", "@unocss/preset-web-fonts": "66.6.6", "@unocss/preset-wind": "66.6.6", "@unocss/preset-wind3": "66.6.6", "@unocss/preset-wind4": "66.6.6", "@unocss/transformer-attributify-jsx": "66.6.6", "@unocss/transformer-compile-class": "66.6.6", "@unocss/transformer-directives": "66.6.6", "@unocss/transformer-variant-group": "66.6.6", "@unocss/vite": "66.6.6" }, "peerDependencies": { "@unocss/astro": "66.6.6", "@unocss/postcss": "66.6.6", "@unocss/webpack": "66.6.6" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
@@ -775,7 +674,7 @@
|
||||
|
||||
"vite-ssr-components": ["vite-ssr-components@0.5.2", "", { "dependencies": { "@babel/parser": "^7.27.2", "@babel/traverse": "^7.27.1", "picomatch": "^4.0.2" } }, "sha512-1a8YThRwyyu1gGjc1Ral9Q4uS+n0D4GydhbkVd9c1SA1YNgXyrOizttped87C1ItEznQzhiCyQjaOcYnXa0zMA=="],
|
||||
|
||||
"vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="],
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"vue-router": ["vue-router@5.0.3", "", { "dependencies": { "@babel/generator": "^7.28.6", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.0.6", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.17", "pinia": "^3.0.4", "vue": "^3.5.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw=="],
|
||||
|
||||
@@ -785,14 +684,8 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"workerd": ["workerd@1.20260301.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260301.1", "@cloudflare/workerd-darwin-arm64": "1.20260301.1", "@cloudflare/workerd-linux-64": "1.20260301.1", "@cloudflare/workerd-linux-arm64": "1.20260301.1", "@cloudflare/workerd-windows-64": "1.20260301.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw=="],
|
||||
|
||||
"wrangler": ["wrangler@4.70.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.14.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260301.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260226.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-PNDZ9o4e+B5x+1bUbz62Hmwz6G9lw+I9pnYe/AguLddJFjfIyt2cmFOUOb3eOZSoXsrhcEPUg2YidYIbVwUkfw=="],
|
||||
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -803,24 +696,24 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"@unocss/config/@unocss/core": ["@unocss/core@66.6.4", "", {}, "sha512-Fii3lhVJVFrKUz6hMGAkq3sXBfNnXB2G8bldNHuBHJpDAoP1F0oO/SU/oSqSjCYvtcD5RtOn8qwzcHuuN3B/mg=="],
|
||||
|
||||
"@vitejs/plugin-vue-jsx/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
|
||||
"@vue/babel-plugin-jsx/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
@@ -829,8 +722,6 @@
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.6", "", {}, "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"unconfig/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
@@ -841,6 +732,26 @@
|
||||
|
||||
"vue-router/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
22
package.json
22
package.json
@@ -4,10 +4,7 @@
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"build": "bunx --bun vite build",
|
||||
"preview": "bunx --bun vite preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"tail": "wrangler tail"
|
||||
"preview": "bunx --bun vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
@@ -16,33 +13,30 @@
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^0.21.7",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.12.5",
|
||||
"i18next": "^25.8.14",
|
||||
"hono": "^4.12.7",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.29",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.26.0",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@types/node": "^25.3.3",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.5",
|
||||
"unocss": "^66.6.6",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.70.0"
|
||||
"vite-ssr-components": "^0.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Result } from "@hiogawa/utils";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
export const baseAPIURL = "https://api.pipic.fun";
|
||||
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
import {
|
||||
proxyTinyRpc,
|
||||
TinyRpcClientAdapter,
|
||||
TinyRpcError,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
import { proxyTinyRpc } from "@hiogawa/tiny-rpc";
|
||||
import { httpClientAdapter } from "@httpClientAdapter";
|
||||
// console.log("httpClientAdapter module:", httpClientAdapter.toString());
|
||||
declare let __host__: string;
|
||||
import type { RpcRoutes } from "@/server/routes/rpc";
|
||||
|
||||
const endpoint = "/rpc";
|
||||
const publicEndpoint = "/rpc-public";
|
||||
const url = import.meta.env.SSR ? "http://localhost" : "";
|
||||
import { type RpcRoutes } from "@/server/routes/rpc";
|
||||
const publicMethods = ["login", "register", "forgotPassword", "resetPassword", "getGoogleLoginUrl"];
|
||||
|
||||
export const client = proxyTinyRpc<RpcRoutes>({
|
||||
adapter: httpClientAdapter({
|
||||
url: url + endpoint,
|
||||
pathsForGET: [],
|
||||
}),
|
||||
});
|
||||
adapter: {
|
||||
send: async (data) => {
|
||||
const targetEndpoint = publicMethods.includes(data.path) ? publicEndpoint : endpoint;
|
||||
return await httpClientAdapter({
|
||||
url: `${url}${targetEndpoint}`,
|
||||
pathsForGET: ["health"],
|
||||
}).send(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import LayoutDashboard from "@/components/icons/LayoutDashboard.vue";
|
||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
||||
// import Upload from "@/components/icons/Upload.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { computed, createStaticVNode, ref } from "vue";
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useTranslation } from "i18next-vue";
|
||||
import { computed, createStaticVNode, ref, VNode } from "vue";
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
@@ -14,40 +15,69 @@ const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => String(auth.user?.role || "").toLowerCase() === "admin");
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
|
||||
const links = computed(() => [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
|
||||
// { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
|
||||
{ href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
|
||||
{ href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/settings", label: t('nav.settings'), icon: SettingsIcon, type: "a", className },
|
||||
]);
|
||||
const links = computed<Record<string, any>>(() => {
|
||||
const baseLinks = [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, action: () => {}, className },
|
||||
{ href: "/", label: t("nav.overview"), icon: Home, action: null, className },
|
||||
{ href: "/videos", label: t("nav.videos"), icon: Video, action: null, className },
|
||||
{
|
||||
href: "/notification",
|
||||
label: t("nav.notification"),
|
||||
icon: Bell,
|
||||
className,
|
||||
action: handleNotificationClick,
|
||||
isActive: isNotificationOpen,
|
||||
},
|
||||
{ href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className },
|
||||
] as const;
|
||||
|
||||
if (isAdmin.value) {
|
||||
return [
|
||||
...baseLinks,
|
||||
{
|
||||
href: "/admin/overview",
|
||||
label: "Admin",
|
||||
icon: LayoutDashboard,
|
||||
action: null,
|
||||
className,
|
||||
} as const,
|
||||
];
|
||||
}
|
||||
|
||||
//v-tooltip="i.label"
|
||||
return baseLinks;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||
|
||||
<template v-for="i in links" :key="i.href">
|
||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
|
||||
)">
|
||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center"
|
||||
>
|
||||
<template v-for="i in links" :key="i.href">
|
||||
<component
|
||||
:name="i.label"
|
||||
:is="i.action ? 'div' : 'router-link'"
|
||||
v-bind="i.action ? {} : { to: i.href }"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value) && 'bg-primary/15',
|
||||
)"
|
||||
>
|
||||
<component
|
||||
:is="i.icon"
|
||||
class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => (isNotificationOpen = val)" />
|
||||
</template>
|
||||
|
||||
58
src/composables/useAdminRuntimeMqtt.ts
Normal file
58
src/composables/useAdminRuntimeMqtt.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TinyMqttClient } from "@/lib/liteMqtt";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { computed, onBeforeUnmount, watch } from "vue";
|
||||
|
||||
type RuntimeMessage = {
|
||||
topic: string;
|
||||
payload: any;
|
||||
};
|
||||
|
||||
const mqttBrokerUrl = "wss://mqtt-dashboard.com:8884/mqtt";
|
||||
|
||||
export function useAdminRuntimeMqtt(onMessage: (message: RuntimeMessage) => void) {
|
||||
const auth = useAuthStore();
|
||||
let client: TinyMqttClient | undefined;
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role?.toUpperCase?.() === "ADMIN");
|
||||
|
||||
const connect = () => {
|
||||
if (import.meta.env.SSR || !isAdmin.value) return;
|
||||
disconnect();
|
||||
client = new TinyMqttClient(
|
||||
mqttBrokerUrl,
|
||||
["picpic/events", "picpic/logs/#", "picpic/job/+"],
|
||||
(topic, raw) => {
|
||||
try {
|
||||
onMessage({ topic, payload: JSON.parse(raw) });
|
||||
} catch {
|
||||
onMessage({ topic, payload: raw });
|
||||
}
|
||||
},
|
||||
);
|
||||
client.connect();
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
client?.disconnect();
|
||||
client = undefined;
|
||||
};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => [auth.user?.id, auth.user?.role],
|
||||
() => {
|
||||
if (isAdmin.value) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWatch();
|
||||
disconnect();
|
||||
});
|
||||
|
||||
return { disconnect };
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
@@ -24,9 +24,7 @@ type NotificationApiItem = {
|
||||
read?: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
action_url?: string;
|
||||
action_label?: string;
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const notifications = ref<AppNotification[]>([]);
|
||||
@@ -69,18 +67,18 @@ export function useNotifications() {
|
||||
type: normalizeType(item.type),
|
||||
title: item.title || '',
|
||||
message: item.message || '',
|
||||
time: formatRelativeTime(item.created_at),
|
||||
time: formatRelativeTime(item.createdAt),
|
||||
read: Boolean(item.read),
|
||||
actionUrl: item.actionUrl || item.action_url || undefined,
|
||||
actionLabel: item.actionLabel || item.action_label || undefined,
|
||||
createdAt: item.created_at,
|
||||
actionUrl: item.actionUrl || undefined,
|
||||
actionLabel: item.actionLabel || undefined,
|
||||
createdAt: item.createdAt,
|
||||
});
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
|
||||
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
|
||||
const response = await rpcClient.listNotifications();
|
||||
notifications.value = (response.notifications || []).map(mapNotification);
|
||||
loaded.value = true;
|
||||
return notifications.value;
|
||||
} finally {
|
||||
@@ -90,24 +88,24 @@ export function useNotifications() {
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
if (!id) return;
|
||||
await client.notifications.readCreate(id, { baseUrl: '/r' });
|
||||
await rpcClient.markNotificationRead({ id });
|
||||
const item = notifications.value.find(notification => notification.id === id);
|
||||
if (item) item.read = true;
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: string) => {
|
||||
if (!id) return;
|
||||
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteNotification({ id });
|
||||
notifications.value = notifications.value.filter(notification => notification.id !== id);
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
await client.notifications.readAllCreate({ baseUrl: '/r' });
|
||||
await rpcClient.markAllNotificationsRead();
|
||||
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
await client.notifications.notificationsDelete({ baseUrl: '/r' });
|
||||
await rpcClient.clearNotifications();
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Preferences } from '@/server/gen/proto/app/v1/common';
|
||||
import type { UpdatePreferencesRequest } from '@/server/gen/proto/app/v1/account';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
|
||||
@@ -36,9 +38,7 @@ export type PlayerSettingsDraft = {
|
||||
};
|
||||
|
||||
type PreferencesResponse = {
|
||||
data?: {
|
||||
preferences?: PreferencesSettingsPreferencesRequest;
|
||||
};
|
||||
preferences?: Preferences;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||
@@ -56,17 +56,17 @@ const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||
};
|
||||
|
||||
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
|
||||
const preferences = (responseData as PreferencesResponse | undefined)?.data?.preferences;
|
||||
const preferences = (responseData as PreferencesResponse | undefined)?.preferences;
|
||||
|
||||
return {
|
||||
emailNotifications: preferences?.email_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
||||
pushNotifications: preferences?.push_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||
marketingNotifications: preferences?.marketing_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||
telegramNotifications: preferences?.telegram_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||
emailNotifications: preferences?.emailNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
||||
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
|
||||
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
|
||||
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
|
||||
showControls: preferences?.show_controls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
|
||||
showControls: preferences?.showControls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
|
||||
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
|
||||
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
|
||||
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
|
||||
@@ -97,20 +97,20 @@ export const createPlayerSettingsDraft = (
|
||||
|
||||
export const toNotificationPreferencesPayload = (
|
||||
draft: NotificationSettingsDraft,
|
||||
): PreferencesSettingsPreferencesRequest => ({
|
||||
email_notifications: draft.email,
|
||||
push_notifications: draft.push,
|
||||
marketing_notifications: draft.marketing,
|
||||
telegram_notifications: draft.telegram,
|
||||
): UpdatePreferencesRequest => ({
|
||||
emailNotifications: draft.email,
|
||||
pushNotifications: draft.push,
|
||||
marketingNotifications: draft.marketing,
|
||||
telegramNotifications: draft.telegram,
|
||||
});
|
||||
|
||||
export const toPlayerPreferencesPayload = (
|
||||
draft: PlayerSettingsDraft,
|
||||
): PreferencesSettingsPreferencesRequest => ({
|
||||
): UpdatePreferencesRequest => ({
|
||||
autoplay: draft.autoplay,
|
||||
loop: draft.loop,
|
||||
muted: draft.muted,
|
||||
show_controls: draft.showControls,
|
||||
showControls: draft.showControls,
|
||||
pip: draft.pip,
|
||||
airplay: draft.airplay,
|
||||
chromecast: draft.chromecast,
|
||||
@@ -120,8 +120,8 @@ export function useSettingsPreferencesQuery() {
|
||||
return useQuery({
|
||||
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
|
||||
query: async () => {
|
||||
const response = await client.settings.preferencesList({ baseUrl: '/r' });
|
||||
return normalizePreferencesSnapshot(response.data);
|
||||
const response = await rpcClient.getPreferences();
|
||||
return normalizePreferencesSnapshot(response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client, ContentType } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -11,34 +11,24 @@ export interface QueueItem {
|
||||
total?: string;
|
||||
speed?: string;
|
||||
thumbnail?: string;
|
||||
file?: File; // Keep reference to file for local uploads
|
||||
url?: string; // Keep reference to url for remote uploads
|
||||
file?: File;
|
||||
url?: string;
|
||||
playbackUrl?: string;
|
||||
videoId?: string;
|
||||
mergeId?: string;
|
||||
// Upload chunk tracking
|
||||
activeChunks?: number;
|
||||
uploadedUrls?: string[];
|
||||
objectKey?: string;
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
const items = ref<QueueItem[]>([]);
|
||||
|
||||
// Upload limits
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
// Chunk upload configuration
|
||||
const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk
|
||||
const MAX_PARALLEL = 3;
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
// Track active XHRs per item id so we can abort them on cancel
|
||||
const activeXhrs = new Map<string, Set<XMLHttpRequest>>();
|
||||
const activeXhrs = new Map<string, XMLHttpRequest>();
|
||||
|
||||
const abortItem = (id: string) => {
|
||||
const xhrs = activeXhrs.get(id);
|
||||
if (xhrs) {
|
||||
xhrs.forEach(xhr => xhr.abort());
|
||||
const xhr = activeXhrs.get(id);
|
||||
if (xhr) {
|
||||
xhr.abort();
|
||||
activeXhrs.delete(id);
|
||||
}
|
||||
};
|
||||
@@ -70,11 +60,9 @@ export function useUploadQueue() {
|
||||
uploaded: '0 MB',
|
||||
total: formatSize(file.size),
|
||||
speed: '0 MB/s',
|
||||
file: file,
|
||||
file,
|
||||
thumbnail: undefined,
|
||||
activeChunks: 0,
|
||||
uploadedUrls: [],
|
||||
cancelled: false
|
||||
cancelled: false,
|
||||
}));
|
||||
|
||||
items.value.push(...newItems);
|
||||
@@ -94,10 +82,8 @@ export function useUploadQueue() {
|
||||
uploaded: '0 MB',
|
||||
total: t('upload.queueItem.unknownSize'),
|
||||
speed: '0 MB/s',
|
||||
url: url,
|
||||
activeChunks: 0,
|
||||
uploadedUrls: [],
|
||||
cancelled: false
|
||||
url,
|
||||
cancelled: false,
|
||||
}));
|
||||
|
||||
items.value.push(...newItems);
|
||||
@@ -118,7 +104,6 @@ export function useUploadQueue() {
|
||||
if (item) {
|
||||
item.cancelled = true;
|
||||
item.status = 'error';
|
||||
item.activeChunks = 0;
|
||||
item.speed = '0 MB/s';
|
||||
}
|
||||
};
|
||||
@@ -127,7 +112,7 @@ export function useUploadQueue() {
|
||||
items.value.forEach(item => {
|
||||
if (item.status === 'pending') {
|
||||
if (item.type === 'local') {
|
||||
startChunkUpload(item.id);
|
||||
startUpload(item.id);
|
||||
} else {
|
||||
startMockRemoteFetch(item.id);
|
||||
}
|
||||
@@ -135,204 +120,147 @@ export function useUploadQueue() {
|
||||
});
|
||||
};
|
||||
|
||||
// Real Chunk Upload Logic
|
||||
const startChunkUpload = async (id: string) => {
|
||||
const startUpload = async (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item || !item.file) return;
|
||||
|
||||
item.status = 'uploading';
|
||||
item.activeChunks = 0;
|
||||
item.uploadedUrls = [];
|
||||
|
||||
const file = item.file;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
|
||||
const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i);
|
||||
|
||||
const updateProgress = () => {
|
||||
let totalUploaded = 0;
|
||||
progressMap.forEach(value => {
|
||||
totalUploaded += value;
|
||||
});
|
||||
const percent = Math.min((totalUploaded / file.size) * 100, 100);
|
||||
item.progress = parseFloat(percent.toFixed(1));
|
||||
item.uploaded = formatSize(totalUploaded);
|
||||
|
||||
// Calculate speed (simplified)
|
||||
const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0;
|
||||
item.speed = formatSize(currentSpeed) + '/s';
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
if (item.cancelled) return;
|
||||
|
||||
const activePromises: Promise<void>[] = [];
|
||||
|
||||
while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) {
|
||||
const index = queue.shift()!;
|
||||
item.activeChunks = (item.activeChunks || 0) + 1;
|
||||
|
||||
const promise = uploadChunk(index, file, progressMap, updateProgress, item)
|
||||
.then(() => {
|
||||
item.activeChunks = (item.activeChunks || 0) - 1;
|
||||
});
|
||||
activePromises.push(promise);
|
||||
}
|
||||
|
||||
if (activePromises.length > 0) {
|
||||
await Promise.all(activePromises);
|
||||
await processQueue();
|
||||
}
|
||||
};
|
||||
item.progress = 0;
|
||||
item.uploaded = '0 MB';
|
||||
item.speed = '0 MB/s';
|
||||
|
||||
try {
|
||||
await processQueue();
|
||||
const response = await rpcClient.getUploadUrl({ filename: item.file.name });
|
||||
if (!response.uploadUrl || !response.key) {
|
||||
throw new Error(t('upload.errors.mergeFailed'));
|
||||
}
|
||||
|
||||
item.objectKey = response.key;
|
||||
await uploadFileToPresignedUrl(item, response.uploadUrl);
|
||||
|
||||
if (!item.cancelled) {
|
||||
item.status = 'processing';
|
||||
await completeUpload(item);
|
||||
}
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
console.error('Upload failed:', error);
|
||||
if (!item.cancelled) {
|
||||
item.status = 'error';
|
||||
console.error('Upload failed:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChunk = (
|
||||
index: number,
|
||||
file: File,
|
||||
progressMap: Map<number, number>,
|
||||
updateProgress: () => void,
|
||||
item: QueueItem
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let retry = 0;
|
||||
const uploadFileToPresignedUrl = async (item: QueueItem, uploadUrl: string) => {
|
||||
if (!item.file) return;
|
||||
|
||||
const attempt = () => {
|
||||
if (item.cancelled) return resolve();
|
||||
|
||||
const start = index * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', chunk, file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
|
||||
|
||||
// Register this XHR so it can be aborted on cancel
|
||||
if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set());
|
||||
activeXhrs.get(item.id)!.add(xhr);
|
||||
|
||||
const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progressMap.set(index, e.loaded);
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
unregister();
|
||||
if (item.cancelled) return resolve();
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.status === 'success') {
|
||||
progressMap.set(index, chunk.size);
|
||||
if (item.uploadedUrls) {
|
||||
item.uploadedUrls[index] = res.data.url;
|
||||
}
|
||||
updateProgress();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
handleError();
|
||||
}
|
||||
}
|
||||
handleError();
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
unregister();
|
||||
resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
unregister();
|
||||
handleError();
|
||||
};
|
||||
|
||||
function handleError() {
|
||||
retry++;
|
||||
if (retry <= MAX_RETRY) {
|
||||
setTimeout(attempt, 2000);
|
||||
} else {
|
||||
item.status = 'error';
|
||||
reject(new Error(t('upload.errors.chunkUploadFailed', { index: index + 1 })));
|
||||
}
|
||||
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||
try {
|
||||
await sendFile(item, uploadUrl);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (item.cancelled) {
|
||||
return;
|
||||
}
|
||||
if (attempt === MAX_RETRY) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
const sendFile = (item: QueueItem, uploadUrl: string): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!item.file) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
const startedAt = Date.now();
|
||||
|
||||
activeXhrs.set(item.id, xhr);
|
||||
xhr.open('PUT', uploadUrl);
|
||||
if (item.file.type) {
|
||||
xhr.setRequestHeader('Content-Type', item.file.type);
|
||||
}
|
||||
|
||||
const cleanup = () => {
|
||||
if (activeXhrs.get(item.id) === xhr) {
|
||||
activeXhrs.delete(item.id);
|
||||
}
|
||||
};
|
||||
|
||||
attempt();
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (!event.lengthComputable || !item.file) return;
|
||||
|
||||
const uploadedBytes = event.loaded;
|
||||
const percent = Math.min((uploadedBytes / item.file.size) * 100, 100);
|
||||
const elapsedSeconds = Math.max((Date.now() - startedAt) / 1000, 0.001);
|
||||
const speed = uploadedBytes / elapsedSeconds;
|
||||
|
||||
item.progress = parseFloat(percent.toFixed(1));
|
||||
item.uploaded = formatSize(uploadedBytes);
|
||||
item.total = formatSize(item.file.size);
|
||||
item.speed = `${formatSize(speed)}/s`;
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
cleanup();
|
||||
if (item.cancelled) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
item.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
item.speed = '0 MB/s';
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 })));
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
cleanup();
|
||||
reject(new Error(t('upload.errors.chunkUploadFailed', { index: 1 })));
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
cleanup();
|
||||
resolve();
|
||||
};
|
||||
|
||||
xhr.send(item.file);
|
||||
});
|
||||
};
|
||||
|
||||
const completeUpload = async (item: QueueItem) => {
|
||||
if (!item.file || !item.uploadedUrls) return;
|
||||
if (!item.file || !item.objectKey) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: item.file.name,
|
||||
chunks: item.uploadedUrls,
|
||||
size: item.file.size
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('upload.errors.mergeFailed'));
|
||||
}
|
||||
|
||||
const playbackUrl = data.playback_url || data.play_url;
|
||||
if (!playbackUrl) {
|
||||
throw new Error('Playback URL missing after merge');
|
||||
}
|
||||
|
||||
const createResponse = await client.videos.videosCreate({
|
||||
const createResponse = await rpcClient.createVideo({
|
||||
title: item.file.name.replace(/\.[^.]+$/, ''),
|
||||
description: '',
|
||||
url: playbackUrl,
|
||||
url: item.objectKey,
|
||||
size: item.file.size,
|
||||
duration: 0,
|
||||
format: item.file.type || 'video/mp4',
|
||||
}, { baseUrl: '/r' });
|
||||
});
|
||||
|
||||
const createdVideo = (createResponse.data as any)?.data?.video || (createResponse.data as any)?.data;
|
||||
const createdVideo = createResponse.video;
|
||||
item.videoId = createdVideo?.id;
|
||||
item.mergeId = data.id;
|
||||
item.playbackUrl = playbackUrl;
|
||||
item.url = playbackUrl;
|
||||
item.playbackUrl = createdVideo?.url || item.objectKey;
|
||||
item.url = createdVideo?.url || item.objectKey;
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
item.speed = '0 MB/s';
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
console.error('Merge failed:', error);
|
||||
console.error('Create video failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Remote Fetch Logic
|
||||
const startMockRemoteFetch = (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
@@ -345,7 +273,6 @@ export function useUploadQueue() {
|
||||
}, 3000 + Math.random() * 3000);
|
||||
};
|
||||
|
||||
|
||||
const formatSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -370,17 +297,11 @@ export function useUploadQueue() {
|
||||
const pendingCount = computed(() => {
|
||||
return items.value.filter(i => i.status === 'pending').length;
|
||||
});
|
||||
|
||||
function removeAll() {
|
||||
items.value = [];
|
||||
}
|
||||
// watch(items, (newItems) => {
|
||||
// // console.log(newItems);
|
||||
// if (newItems.length === 0) return;
|
||||
// if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) {
|
||||
// // startQueue();
|
||||
// items.value = [];
|
||||
// }
|
||||
// }, { deep: true });
|
||||
|
||||
return {
|
||||
items,
|
||||
addFiles,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
export const USAGE_QUERY_KEY = ['usage'] as const;
|
||||
@@ -9,10 +9,8 @@ export type UsageSnapshot = {
|
||||
};
|
||||
|
||||
type UsageResponse = {
|
||||
data?: {
|
||||
total_videos?: number;
|
||||
total_storage?: number;
|
||||
};
|
||||
totalVideos?: number;
|
||||
totalStorage?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
||||
@@ -21,11 +19,11 @@ const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
||||
};
|
||||
|
||||
const normalizeUsageSnapshot = (responseData: unknown): UsageSnapshot => {
|
||||
const usage = (responseData as UsageResponse | undefined)?.data;
|
||||
const usage = responseData as UsageResponse | undefined;
|
||||
|
||||
return {
|
||||
totalVideos: usage?.total_videos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
||||
totalStorage: usage?.total_storage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
||||
totalVideos: usage?.totalVideos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
||||
totalStorage: usage?.totalStorage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,8 +31,8 @@ export function useUsageQuery() {
|
||||
return useQuery({
|
||||
key: () => USAGE_QUERY_KEY,
|
||||
query: async () => {
|
||||
const response = await client.usage.usageList({ baseUrl: '/r' });
|
||||
return normalizeUsageSnapshot(response.data);
|
||||
const response = await rpcClient.getUsage();
|
||||
return normalizeUsageSnapshot(response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { setupMiddlewares } from './server/middlewares/setup';
|
||||
import { registerDisplayRoutes } from './server/routes/display';
|
||||
import { registerManifestRoutes } from './server/routes/manifest';
|
||||
import { registerMergeRoutes } from './server/routes/merge';
|
||||
import { registerSSRRoutes } from './server/routes/ssr';
|
||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
||||
import { setupServices } from './server/services/grpcClient';
|
||||
import { registerRpcRoutes } from './server/routes/rpc';
|
||||
import { registerAuthRoutes } from './server/routes/auth';
|
||||
const app = new Hono();
|
||||
|
||||
// Global middlewares
|
||||
@@ -15,10 +13,8 @@ setupMiddlewares(app);
|
||||
setupServices(app);
|
||||
// Routes
|
||||
registerWellKnownRoutes(app);
|
||||
registerAuthRoutes(app);
|
||||
registerRpcRoutes(app);
|
||||
registerMergeRoutes(app);
|
||||
registerDisplayRoutes(app);
|
||||
registerManifestRoutes(app);
|
||||
registerSSRRoutes(app);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelVideo } from "@/api/client";
|
||||
import type { Video as ModelVideo } from "@/server/gen/proto/app/v1/common";
|
||||
|
||||
export const mockVideos: ModelVideo[] = [
|
||||
{
|
||||
@@ -9,7 +9,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 345, // 5m 45s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 45, // 45MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||
views: 12500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -20,9 +20,8 @@ export const mockVideos: ModelVideo[] = [
|
||||
thumbnail: 'https://picsum.photos/seed/video2/640/360',
|
||||
duration: 890, // 14m 50s
|
||||
status: 'processing',
|
||||
processing_status: '75%',
|
||||
size: 1024 * 1024 * 128, // 128MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
@@ -34,7 +33,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 120, // 2m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 25, // 25MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||
views: 340,
|
||||
url: '#'
|
||||
},
|
||||
@@ -46,7 +45,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 1800, // 30m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 350, // 350MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||
views: 12,
|
||||
url: '#'
|
||||
},
|
||||
@@ -58,7 +57,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 600, // 10m 00s
|
||||
status: 'failed',
|
||||
size: 1024 * 1024 * 80, // 80MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
@@ -70,7 +69,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 5400, // 1h 30m
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 1024 * 2.5, // 2.5GB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||
views: 45000,
|
||||
url: '#'
|
||||
},
|
||||
@@ -82,7 +81,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 1540,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 200,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
views: 8900,
|
||||
url: '#'
|
||||
},
|
||||
@@ -94,7 +93,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -106,7 +105,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -118,7 +117,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -130,7 +129,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -142,7 +141,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -154,7 +153,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -166,7 +165,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -178,7 +177,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -190,7 +189,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -202,7 +201,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -214,7 +213,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -226,7 +225,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -238,7 +237,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -250,7 +249,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -262,7 +261,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -274,7 +273,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -340,7 +339,7 @@ export const updateMockVideo = async (id: string, updates: { title: string; desc
|
||||
...mockVideos[videoIndex],
|
||||
title: updates.title,
|
||||
description: updates.description,
|
||||
updated_at: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return mockVideos[videoIndex];
|
||||
};
|
||||
|
||||
334
src/routes/admin/AdTemplates.vue
Normal file
334
src/routes/admin/AdTemplates.vue
Normal file
@@ -0,0 +1,334 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminAdTemplateRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAdTemplateRow[]>([]);
|
||||
const selectedRow = ref<AdminAdTemplateRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const formatOptions = ["pre-roll", "mid-roll", "post-roll"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
vastTagUrl: "",
|
||||
adFormat: "pre-roll",
|
||||
duration: null as number | null,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
description: "",
|
||||
vastTagUrl: "",
|
||||
adFormat: "pre-roll",
|
||||
duration: null as number | null,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.name.trim() && createForm.vastTagUrl.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.name.trim() && editForm.vastTagUrl.trim());
|
||||
|
||||
const loadTemplates = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminAdTemplates({ page: 1, limit: 20 });
|
||||
rows.value = response.templates ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin ad templates";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.name = "";
|
||||
createForm.description = "";
|
||||
createForm.vastTagUrl = "";
|
||||
createForm.adFormat = "pre-roll";
|
||||
createForm.duration = null;
|
||||
createForm.isActive = true;
|
||||
createForm.isDefault = false;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.userId = row.userId || "";
|
||||
editForm.name = row.name || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.vastTagUrl = row.vastTagUrl || "";
|
||||
editForm.adFormat = row.adFormat || "pre-roll";
|
||||
editForm.duration = row.duration ?? null;
|
||||
editForm.isActive = !!row.isActive;
|
||||
editForm.isDefault = !!row.isDefault;
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminAdTemplateRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminAdTemplate({
|
||||
userId: createForm.userId.trim(),
|
||||
name: createForm.name.trim(),
|
||||
description: createForm.description.trim() || undefined,
|
||||
vastTagUrl: createForm.vastTagUrl.trim(),
|
||||
adFormat: createForm.adFormat,
|
||||
duration: createForm.duration == null ? undefined : createForm.duration,
|
||||
isActive: createForm.isActive,
|
||||
isDefault: createForm.isDefault,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminAdTemplate({
|
||||
id: editForm.id,
|
||||
userId: editForm.userId.trim(),
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || undefined,
|
||||
vastTagUrl: editForm.vastTagUrl.trim(),
|
||||
adFormat: editForm.adFormat,
|
||||
duration: editForm.duration == null ? undefined : editForm.duration,
|
||||
isActive: editForm.isActive,
|
||||
isDefault: editForm.isDefault,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminAdTemplate({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadTemplates();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete ad template";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadTemplates);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Ad Templates"
|
||||
description="Cross-user ad template management over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create template</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Default</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">Loading ad templates...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="6" class="py-6 text-center text-gray-500">No ad templates found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.vastTagUrl }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.adFormat || 'pre-roll' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isDefault ? 'YES' : 'NO' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Preroll template" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="createForm.vastTagUrl" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="createForm.adFormat" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="createForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit ad template" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">VAST URL</label>
|
||||
<AppInput v-model="editForm.vastTagUrl" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad format</label>
|
||||
<select v-model="editForm.adFormat" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="format in formatOptions" :key="format" :value="format">{{ format }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input v-model="editForm.isDefault" type="checkbox" class="h-4 w-4" />
|
||||
Default
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete ad template" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete ad template <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
191
src/routes/admin/Agents.vue
Normal file
191
src/routes/admin/Agents.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminAgentRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminAgentRow[]>([]);
|
||||
const selectedRow = ref<AdminAgentRow | null>(null);
|
||||
const restartOpen = ref(false);
|
||||
const updateOpen = ref(false);
|
||||
|
||||
const loadAgents = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminAgents();
|
||||
rows.value = response.agents ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin agents";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
restartOpen.value = false;
|
||||
updateOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openRestartDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
restartOpen.value = true;
|
||||
};
|
||||
|
||||
const openUpdateDialog = (row: AdminAgentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
updateOpen.value = true;
|
||||
};
|
||||
|
||||
const submitRestart = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.restartAdminAgent({ id: selectedRow.value.id });
|
||||
restartOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to restart agent";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitUpdate = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminAgent({ id: selectedRow.value.id });
|
||||
updateOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadAgents();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update agent";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic !== "picpic/events" || payload?.type !== "agent_update") return;
|
||||
const update = payload.payload;
|
||||
if (!update?.id) return;
|
||||
const row = rows.value.find((item) => item.id === update.id);
|
||||
if (row) {
|
||||
Object.assign(row, {
|
||||
...row,
|
||||
...update,
|
||||
lastHeartbeat: update.last_heartbeat || row.lastHeartbeat,
|
||||
createdAt: update.created_at || row.createdAt,
|
||||
updatedAt: update.updated_at || row.updatedAt,
|
||||
});
|
||||
} else {
|
||||
loadAgents();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadAgents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Agents"
|
||||
description="Connected render workers and command controls over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" variant="secondary" @click="loadAgents">Refresh agents</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Platform</th>
|
||||
<th class="py-3 pr-4 font-medium">Version</th>
|
||||
<th class="py-3 pr-4 font-medium">CPU</th>
|
||||
<th class="py-3 pr-4 font-medium">RAM</th>
|
||||
<th class="py-3 pr-4 font-medium">Heartbeat</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">Loading agents...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No agents connected.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.platform || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.version || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.cpu ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ram ?? 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.lastHeartbeat ? new Date(row.lastHeartbeat).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openUpdateDialog(row)">Update</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openRestartDialog(row)">Restart</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="restartOpen" title="Restart agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send restart command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitRestart">Restart</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="updateOpen" title="Update agent" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Send update command to <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" @click="submitUpdate">Update</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
362
src/routes/admin/Jobs.vue
Normal file
362
src/routes/admin/Jobs.vue
Normal file
@@ -0,0 +1,362 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminJobRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminJobRow[]>([]);
|
||||
const selectedRow = ref<AdminJobRow | null>(null);
|
||||
const selectedLogs = ref("");
|
||||
const createOpen = ref(false);
|
||||
const logsOpen = ref(false);
|
||||
const cancelOpen = ref(false);
|
||||
const retryOpen = ref(false);
|
||||
const activeAgentFilter = ref("");
|
||||
|
||||
const createForm = reactive({
|
||||
command: "",
|
||||
image: "alpine",
|
||||
userId: "",
|
||||
name: "",
|
||||
timeLimit: 0,
|
||||
priority: 0,
|
||||
envText: "",
|
||||
});
|
||||
|
||||
const parseEnvText = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.reduce<Record<string, string>>((acc, line) => {
|
||||
const separatorIndex = line.indexOf("=");
|
||||
if (separatorIndex === -1) return acc;
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
const val = line.slice(separatorIndex + 1).trim();
|
||||
if (key) {
|
||||
acc[key] = val;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const hasEnv = computed(() => Object.keys(parseEnvText(createForm.envText)).length > 0);
|
||||
const canCreate = computed(() => createForm.command.trim().length > 0);
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.command = "";
|
||||
createForm.image = "alpine";
|
||||
createForm.userId = "";
|
||||
createForm.name = "";
|
||||
createForm.timeLimit = 0;
|
||||
createForm.priority = 0;
|
||||
createForm.envText = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
logsOpen.value = false;
|
||||
cancelOpen.value = false;
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
selectedLogs.value = "";
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadJobs = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminJobs({
|
||||
offset: 0,
|
||||
limit: 50,
|
||||
agentId: activeAgentFilter.value.trim() || undefined,
|
||||
});
|
||||
rows.value = response.jobs ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin jobs";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openLogsDialog = async (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
selectedLogs.value = "Loading logs...";
|
||||
logsOpen.value = true;
|
||||
try {
|
||||
const response = await rpcClient.getAdminJobLogs({ id: row.id });
|
||||
selectedLogs.value = response.logs || "No logs available.";
|
||||
} catch (err: any) {
|
||||
selectedLogs.value = "";
|
||||
actionError.value = err?.message || "Failed to load job logs";
|
||||
}
|
||||
};
|
||||
|
||||
const openCancelDialog = (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
cancelOpen.value = true;
|
||||
};
|
||||
|
||||
const openRetryDialog = (row: AdminJobRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
retryOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminJob({
|
||||
command: createForm.command.trim(),
|
||||
image: createForm.image.trim() || undefined,
|
||||
userId: createForm.userId.trim() || undefined,
|
||||
name: createForm.name.trim() || undefined,
|
||||
timeLimit: createForm.timeLimit > 0 ? createForm.timeLimit : undefined,
|
||||
priority: createForm.priority,
|
||||
env: hasEnv.value ? parseEnvText(createForm.envText) : undefined,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitCancel = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.cancelAdminJob({ id: selectedRow.value.id });
|
||||
cancelOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to cancel job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitRetry = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.retryAdminJob({ id: selectedRow.value.id });
|
||||
retryOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadJobs();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to retry job";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (topic.startsWith("picpic/job/") && payload?.type === "job_update") {
|
||||
const update = payload.payload;
|
||||
const jobId = update?.job_id;
|
||||
const status = update?.status;
|
||||
if (!jobId || !status) return;
|
||||
const row = rows.value.find((item) => item.id === jobId);
|
||||
if (row) {
|
||||
row.status = status;
|
||||
row.updatedAt = new Date().toISOString();
|
||||
} else {
|
||||
loadJobs();
|
||||
}
|
||||
}
|
||||
|
||||
if (topic.startsWith("picpic/logs/") && payload?.job_id) {
|
||||
const row = rows.value.find((item) => item.id === payload.job_id);
|
||||
if (row && typeof payload.line === "string") {
|
||||
row.logs = `${row.logs || ""}${payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`}`;
|
||||
row.progress = payload.progress ?? row.progress;
|
||||
}
|
||||
if (selectedRow.value?.id === payload.job_id && typeof payload.line === "string") {
|
||||
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
|
||||
selectedLogs.value = `${selectedLogs.value === "Loading logs..." ? "" : selectedLogs.value}${nextLine}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (topic === "picpic/events" && payload?.type === "resource_update") {
|
||||
const update = payload.payload;
|
||||
if (!update?.agent_id) return;
|
||||
rows.value.forEach((row) => {
|
||||
if (row.agentId === update.agent_id) {
|
||||
row.updatedAt = new Date().toISOString();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(loadJobs);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Jobs"
|
||||
description="Runtime job queue over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div class="w-full max-w-sm space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Filter by agent ID</label>
|
||||
<div class="flex gap-2">
|
||||
<AppInput v-model="activeAgentFilter" placeholder="Optional agent ID" />
|
||||
<AppButton size="sm" variant="secondary" @click="loadJobs">Apply</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create job</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Agent</th>
|
||||
<th class="py-3 pr-4 font-medium">Priority</th>
|
||||
<th class="py-3 pr-4 font-medium">Progress</th>
|
||||
<th class="py-3 pr-4 font-medium">Updated</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading jobs...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No jobs found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name || row.id }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.id }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.agentId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.priority }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.progress || 0 }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.updatedAt ? new Date(row.updatedAt).toLocaleString() : '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openLogsDialog(row)">Logs</AppButton>
|
||||
<AppButton size="sm" variant="secondary" @click="openRetryDialog(row)">Retry</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openCancelDialog(row)">Cancel</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create job" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Command</label>
|
||||
<AppInput v-model="createForm.command" placeholder="ffmpeg -i ..." />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Image</label>
|
||||
<AppInput v-model="createForm.image" placeholder="alpine" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Display name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Priority</label>
|
||||
<AppInput v-model="createForm.priority" type="number" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Time limit</label>
|
||||
<AppInput v-model="createForm.timeLimit" type="number" min="0" placeholder="Seconds" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Environment</label>
|
||||
<textarea v-model="createForm.envText" rows="5" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="KEY=value per line" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="logsOpen" title="Job logs" maxWidthClass="max-w-3xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-950 p-4 font-mono text-xs text-green-300 whitespace-pre-wrap max-h-120 overflow-auto">
|
||||
{{ selectedLogs || 'No logs available.' }}
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" @click="closeDialogs">Close</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="cancelOpen" title="Cancel job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Cancel job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitCancel">Cancel job</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="retryOpen" title="Retry job" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Retry job <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Back</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" @click="submitRetry">Retry</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
3
src/routes/admin/Layout.vue
Normal file
3
src/routes/admin/Layout.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
59
src/routes/admin/Logs.vue
Normal file
59
src/routes/admin/Logs.vue
Normal file
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useAdminRuntimeMqtt } from "@/composables/useAdminRuntimeMqtt";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const jobId = ref("");
|
||||
const logs = ref("Enter a job ID and load logs.");
|
||||
|
||||
const loadLogs = async () => {
|
||||
if (!jobId.value.trim()) return;
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.getAdminJobLogs({ id: jobId.value.trim() });
|
||||
logs.value = response.logs || "No logs available.";
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load logs";
|
||||
logs.value = "";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
useAdminRuntimeMqtt(({ topic, payload }) => {
|
||||
if (!jobId.value.trim()) return;
|
||||
if (topic === `picpic/logs/${jobId.value.trim()}` && payload?.job_id === jobId.value.trim() && typeof payload.line === "string") {
|
||||
const nextLine = payload.line.endsWith("\n") ? payload.line : `${payload.line}\n`;
|
||||
logs.value = `${logs.value === "Enter a job ID and load logs." ? "" : logs.value}${nextLine}`;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Logs"
|
||||
description="Fetch persisted logs by job ID over admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex flex-col gap-3 md:flex-row md:items-end">
|
||||
<div class="w-full max-w-xl space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Job ID</label>
|
||||
<AppInput v-model="jobId" placeholder="job-..." />
|
||||
</div>
|
||||
<AppButton size="sm" :loading="loading" @click="loadLogs">Load logs</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-gray-200 bg-gray-950 p-4 font-mono text-sm text-green-300 whitespace-pre-wrap min-h-80 overflow-auto">
|
||||
{{ loading ? 'Loading logs...' : logs }}
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
55
src/routes/admin/Overview.vue
Normal file
55
src/routes/admin/Overview.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import StatsCard from "@/components/dashboard/StatsCard.vue";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
const loading = ref(true);
|
||||
const error = ref<string | null>(null);
|
||||
const dashboard = ref<any | null>(null);
|
||||
|
||||
const cards = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ title: "Total users", value: data?.totalUsers ?? 0, color: "primary" as const },
|
||||
{ title: "Total videos", value: data?.totalVideos ?? 0, color: "info" as const },
|
||||
{ title: "Payments", value: data?.totalPayments ?? 0, color: "success" as const },
|
||||
{ title: "Revenue", value: data?.totalRevenue ?? 0, color: "warning" as const },
|
||||
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0, color: "primary" as const },
|
||||
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0, color: "info" as const },
|
||||
{ title: "New users today", value: data?.newUsersToday ?? 0, color: "success" as const },
|
||||
{ title: "New videos today", value: data?.newVideosToday ?? 0, color: "warning" as const },
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
dashboard.value = await rpcClient.getAdminDashboard();
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin dashboard";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Overview"
|
||||
description="System-wide metrics from backend gRPC admin service."
|
||||
>
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<StatsCard
|
||||
v-for="card in cards"
|
||||
:key="card.title"
|
||||
:title="card.title"
|
||||
:value="loading ? 0 : card.value"
|
||||
:color="card.color"
|
||||
/>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
</template>
|
||||
223
src/routes/admin/Payments.vue
Normal file
223
src/routes/admin/Payments.vue
Normal file
@@ -0,0 +1,223 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminPaymentRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPaymentRow[]>([]);
|
||||
const selectedRow = ref<AdminPaymentRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const statusOpen = ref(false);
|
||||
|
||||
const paymentMethodOptions = ["TOPUP", "WALLET"];
|
||||
const statusOptions = ["PENDING", "SUCCESS", "FAILED", "CANCELLED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
planId: "",
|
||||
termMonths: 1,
|
||||
paymentMethod: "TOPUP",
|
||||
topupAmount: null as number | null,
|
||||
});
|
||||
|
||||
const statusForm = reactive({
|
||||
id: "",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.planId.trim() && createForm.termMonths >= 1 && createForm.paymentMethod.trim());
|
||||
const canUpdateStatus = computed(() => statusForm.id.trim() && statusForm.status.trim());
|
||||
|
||||
const loadPayments = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminPayments({ page: 1, limit: 20 });
|
||||
rows.value = response.payments ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin payments";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.planId = "";
|
||||
createForm.termMonths = 1;
|
||||
createForm.paymentMethod = "TOPUP";
|
||||
createForm.topupAmount = null;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openStatusDialog = (row: AdminPaymentRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
statusForm.id = row.id || "";
|
||||
statusForm.status = row.status || "PENDING";
|
||||
statusOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminPayment({
|
||||
userId: createForm.userId.trim(),
|
||||
planId: createForm.planId.trim(),
|
||||
termMonths: createForm.termMonths,
|
||||
paymentMethod: createForm.paymentMethod,
|
||||
topupAmount: createForm.topupAmount == null ? undefined : createForm.topupAmount,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadPayments();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create payment";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitStatusUpdate = async () => {
|
||||
if (!canUpdateStatus.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminPayment({
|
||||
id: statusForm.id,
|
||||
status: statusForm.status,
|
||||
});
|
||||
statusOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPayments();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update payment";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPayments);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Payments"
|
||||
description="Payment history from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create payment</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">User</th>
|
||||
<th class="py-3 pr-4 font-medium">Amount</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Method</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading payments...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No payments found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.userEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.amount }} {{ row.currency }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.paymentMethod || '—' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openStatusDialog(row)">Update status</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin payment" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="createForm.planId" placeholder="plan-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Term months</label>
|
||||
<AppInput v-model="createForm.termMonths" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Payment method</label>
|
||||
<select v-model="createForm.paymentMethod" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="method in paymentMethodOptions" :key="method" :value="method">{{ method }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Topup amount</label>
|
||||
<AppInput v-model="createForm.topupAmount" type="number" min="0" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="statusOpen" title="Update payment status" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="statusForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateStatus" @click="submitStatusUpdate">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
342
src/routes/admin/Plans.vue
Normal file
342
src/routes/admin/Plans.vue
Normal file
@@ -0,0 +1,342 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminPlanRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminPlanRow[]>([]);
|
||||
const selectedRow = ref<AdminPlanRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const cycleOptions = ["monthly", "quarterly", "yearly"];
|
||||
|
||||
const createForm = reactive({
|
||||
name: "",
|
||||
description: "",
|
||||
featuresText: "",
|
||||
price: 0,
|
||||
cycle: "monthly",
|
||||
storageLimit: 1,
|
||||
uploadLimit: 1,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
name: "",
|
||||
description: "",
|
||||
featuresText: "",
|
||||
price: 0,
|
||||
cycle: "monthly",
|
||||
storageLimit: 1,
|
||||
uploadLimit: 1,
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
const parseFeatures = (value: string) =>
|
||||
value
|
||||
.split("\n")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const canCreate = computed(() => createForm.name.trim() && createForm.cycle.trim() && createForm.storageLimit > 0 && createForm.uploadLimit > 0);
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.name.trim() && editForm.cycle.trim() && editForm.storageLimit > 0 && editForm.uploadLimit > 0);
|
||||
|
||||
const loadPlans = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminPlans();
|
||||
rows.value = response.plans ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin plans";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.name = "";
|
||||
createForm.description = "";
|
||||
createForm.featuresText = "";
|
||||
createForm.price = 0;
|
||||
createForm.cycle = "monthly";
|
||||
createForm.storageLimit = 1;
|
||||
createForm.uploadLimit = 1;
|
||||
createForm.isActive = true;
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.name = row.name || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.featuresText = (row.features ?? []).join("\n");
|
||||
editForm.price = row.price ?? 0;
|
||||
editForm.cycle = row.cycle || "monthly";
|
||||
editForm.storageLimit = row.storageLimit ?? 1;
|
||||
editForm.uploadLimit = row.uploadLimit ?? 1;
|
||||
editForm.isActive = !!row.isActive;
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminPlanRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminPlan({
|
||||
name: createForm.name.trim(),
|
||||
description: createForm.description.trim() || undefined,
|
||||
features: parseFeatures(createForm.featuresText),
|
||||
price: createForm.price,
|
||||
cycle: createForm.cycle,
|
||||
storageLimit: createForm.storageLimit,
|
||||
uploadLimit: createForm.uploadLimit,
|
||||
isActive: createForm.isActive,
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminPlan({
|
||||
id: editForm.id,
|
||||
name: editForm.name.trim(),
|
||||
description: editForm.description.trim() || undefined,
|
||||
features: parseFeatures(editForm.featuresText),
|
||||
price: editForm.price,
|
||||
cycle: editForm.cycle,
|
||||
storageLimit: editForm.storageLimit,
|
||||
uploadLimit: editForm.uploadLimit,
|
||||
isActive: editForm.isActive,
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminPlan({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadPlans();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete plan";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadPlans);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Plans"
|
||||
description="Subscription plans managed via admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create plan</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">Name</th>
|
||||
<th class="py-3 pr-4 font-medium">Price</th>
|
||||
<th class="py-3 pr-4 font-medium">Cycle</th>
|
||||
<th class="py-3 pr-4 font-medium">Storage</th>
|
||||
<th class="py-3 pr-4 font-medium">Uploads</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading plans...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No plans found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">
|
||||
<div class="font-medium">{{ row.name }}</div>
|
||||
<div class="text-xs text-gray-500">{{ row.description || '—' }}</div>
|
||||
</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.price }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.cycle }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.storageLimit }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.uploadLimit }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.isActive ? 'ACTIVE' : 'INACTIVE' }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin plan" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="createForm.name" placeholder="Starter" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="createForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="One feature per line" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="createForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="createForm.cycle" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="createForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="createForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<input v-model="createForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit plan" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Name</label>
|
||||
<AppInput v-model="editForm.name" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Features</label>
|
||||
<textarea v-model="editForm.featuresText" rows="4" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Price</label>
|
||||
<AppInput v-model="editForm.price" type="number" min="0" step="0.01" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Cycle</label>
|
||||
<select v-model="editForm.cycle" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="cycle in cycleOptions" :key="cycle" :value="cycle">{{ cycle }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Storage limit</label>
|
||||
<AppInput v-model="editForm.storageLimit" type="number" min="1" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Upload limit</label>
|
||||
<AppInput v-model="editForm.uploadLimit" type="number" min="1" />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 md:col-span-2">
|
||||
<input v-model="editForm.isActive" type="checkbox" class="h-4 w-4" />
|
||||
Active
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete plan" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete or deactivate plan <span class="font-medium">{{ selectedRow?.name || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
351
src/routes/admin/Users.vue
Normal file
351
src/routes/admin/Users.vue
Normal file
@@ -0,0 +1,351 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminUserRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminUserRow[]>([]);
|
||||
const roleOptions = ["USER", "ADMIN"];
|
||||
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const roleOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const selectedRow = ref<AdminUserRow | null>(null);
|
||||
|
||||
const createForm = reactive({
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "USER",
|
||||
planId: "",
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
email: "",
|
||||
username: "",
|
||||
password: "",
|
||||
role: "USER",
|
||||
planId: "",
|
||||
});
|
||||
|
||||
const roleForm = reactive({
|
||||
id: "",
|
||||
role: "USER",
|
||||
});
|
||||
|
||||
const canCreate = computed(() => createForm.email.trim() && createForm.password.trim() && createForm.role.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.email.trim() && editForm.role.trim());
|
||||
const canUpdateRole = computed(() => roleForm.id.trim() && roleForm.role.trim());
|
||||
|
||||
const normalizeOptional = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.email = "";
|
||||
createForm.username = "";
|
||||
createForm.password = "";
|
||||
createForm.role = "USER";
|
||||
createForm.planId = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
roleOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadUsers = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminUsers({ page: 1, limit: 20 });
|
||||
rows.value = response.users ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin users";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.email = row.email || "";
|
||||
editForm.username = row.username || "";
|
||||
editForm.password = "";
|
||||
editForm.role = row.role || "USER";
|
||||
editForm.planId = row.planId || "";
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openRoleDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
roleForm.id = row.id || "";
|
||||
roleForm.role = row.role || "USER";
|
||||
roleOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminUserRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminUser({
|
||||
email: createForm.email.trim(),
|
||||
username: normalizeOptional(createForm.username),
|
||||
password: createForm.password,
|
||||
role: createForm.role,
|
||||
planId: normalizeOptional(createForm.planId),
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminUser({
|
||||
id: editForm.id,
|
||||
email: editForm.email.trim(),
|
||||
username: normalizeOptional(editForm.username),
|
||||
password: normalizeOptional(editForm.password),
|
||||
role: editForm.role,
|
||||
planId: normalizeOptional(editForm.planId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitRole = async () => {
|
||||
if (!canUpdateRole.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminUserRole({
|
||||
id: roleForm.id,
|
||||
role: roleForm.role,
|
||||
});
|
||||
roleOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update role";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminUser({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadUsers();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete user";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadUsers);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Users"
|
||||
description="User management data from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create user</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Username</th>
|
||||
<th class="py-3 pr-4 font-medium">Email</th>
|
||||
<th class="py-3 pr-4 font-medium">Role</th>
|
||||
<th class="py-3 pr-4 font-medium">Plan</th>
|
||||
<th class="py-3 pr-4 font-medium">Videos</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading users...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No users found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.username || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.email }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.role || 'USER' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.planName || row.planId || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.videoCount ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="ghost" @click="openRoleDialog(row)">Role</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin user" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="createForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="createForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="createForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Password</label>
|
||||
<AppInput v-model="createForm.password" type="password" placeholder="Minimum 6 characters" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="createForm.planId" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit user" maxWidthClass="max-w-lg" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Email</label>
|
||||
<AppInput v-model="editForm.email" placeholder="user@example.com" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Username</label>
|
||||
<AppInput v-model="editForm.username" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="editForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Reset password</label>
|
||||
<AppInput v-model="editForm.password" type="password" placeholder="Leave blank to keep current" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Plan ID</label>
|
||||
<AppInput v-model="editForm.planId" placeholder="Optional" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="roleOpen" title="Update user role" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Role</label>
|
||||
<select v-model="roleForm.role" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="role in roleOptions" :key="role" :value="role">{{ role }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdateRole" @click="submitRole">Update role</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete user" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete <span class="font-medium">{{ selectedRow?.email || selectedRow?.id }}</span> and related data.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
354
src/routes/admin/Videos.vue
Normal file
354
src/routes/admin/Videos.vue
Normal file
@@ -0,0 +1,354 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import AppButton from "@/components/app/AppButton.vue";
|
||||
import AppDialog from "@/components/app/AppDialog.vue";
|
||||
import AppInput from "@/components/app/AppInput.vue";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import AdminSectionShell from "./components/AdminSectionShell.vue";
|
||||
|
||||
type AdminVideoRow = any;
|
||||
|
||||
const loading = ref(true);
|
||||
const submitting = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const actionError = ref<string | null>(null);
|
||||
const rows = ref<AdminVideoRow[]>([]);
|
||||
const selectedRow = ref<AdminVideoRow | null>(null);
|
||||
const createOpen = ref(false);
|
||||
const editOpen = ref(false);
|
||||
const deleteOpen = ref(false);
|
||||
const statusOptions = ["UPLOADED", "PROCESSING", "READY", "FAILED"];
|
||||
|
||||
const createForm = reactive({
|
||||
userId: "",
|
||||
title: "",
|
||||
description: "",
|
||||
url: "",
|
||||
size: null as number | null,
|
||||
duration: null as number | null,
|
||||
format: "",
|
||||
status: "READY",
|
||||
adTemplateId: "",
|
||||
});
|
||||
|
||||
const editForm = reactive({
|
||||
id: "",
|
||||
userId: "",
|
||||
title: "",
|
||||
description: "",
|
||||
url: "",
|
||||
size: null as number | null,
|
||||
duration: null as number | null,
|
||||
format: "",
|
||||
status: "READY",
|
||||
adTemplateId: "",
|
||||
});
|
||||
|
||||
const normalizeOptional = (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
};
|
||||
|
||||
const normalizeNumber = (value: number | null) => (value == null ? undefined : value);
|
||||
|
||||
const canCreate = computed(() => createForm.userId.trim() && createForm.title.trim() && createForm.url.trim() && createForm.status.trim());
|
||||
const canUpdate = computed(() => editForm.id.trim() && editForm.userId.trim() && editForm.title.trim() && editForm.url.trim() && editForm.status.trim());
|
||||
|
||||
const resetCreateForm = () => {
|
||||
createForm.userId = "";
|
||||
createForm.title = "";
|
||||
createForm.description = "";
|
||||
createForm.url = "";
|
||||
createForm.size = null;
|
||||
createForm.duration = null;
|
||||
createForm.format = "";
|
||||
createForm.status = "READY";
|
||||
createForm.adTemplateId = "";
|
||||
};
|
||||
|
||||
const closeDialogs = () => {
|
||||
createOpen.value = false;
|
||||
editOpen.value = false;
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
actionError.value = null;
|
||||
};
|
||||
|
||||
const loadVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await rpcClient.listAdminVideos({ page: 1, limit: 20 });
|
||||
rows.value = response.videos ?? [];
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || "Failed to load admin videos";
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
editForm.id = row.id || "";
|
||||
editForm.userId = row.userId || "";
|
||||
editForm.title = row.title || "";
|
||||
editForm.description = row.description || "";
|
||||
editForm.url = row.url || "";
|
||||
editForm.size = row.size ?? null;
|
||||
editForm.duration = row.duration ?? null;
|
||||
editForm.format = row.format || "";
|
||||
editForm.status = row.status || "READY";
|
||||
editForm.adTemplateId = row.adTemplateId || "";
|
||||
editOpen.value = true;
|
||||
};
|
||||
|
||||
const openDeleteDialog = (row: AdminVideoRow) => {
|
||||
selectedRow.value = row;
|
||||
actionError.value = null;
|
||||
deleteOpen.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!canCreate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.createAdminVideo({
|
||||
userId: createForm.userId.trim(),
|
||||
title: createForm.title.trim(),
|
||||
description: normalizeOptional(createForm.description),
|
||||
url: createForm.url.trim(),
|
||||
size: normalizeNumber(createForm.size),
|
||||
duration: normalizeNumber(createForm.duration),
|
||||
format: normalizeOptional(createForm.format),
|
||||
status: createForm.status,
|
||||
adTemplateId: normalizeOptional(createForm.adTemplateId),
|
||||
});
|
||||
resetCreateForm();
|
||||
createOpen.value = false;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to create video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitEdit = async () => {
|
||||
if (!canUpdate.value) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.updateAdminVideo({
|
||||
id: editForm.id,
|
||||
userId: editForm.userId.trim(),
|
||||
title: editForm.title.trim(),
|
||||
description: normalizeOptional(editForm.description),
|
||||
url: editForm.url.trim(),
|
||||
size: normalizeNumber(editForm.size),
|
||||
duration: normalizeNumber(editForm.duration),
|
||||
format: normalizeOptional(editForm.format),
|
||||
status: editForm.status,
|
||||
adTemplateId: normalizeOptional(editForm.adTemplateId),
|
||||
});
|
||||
editOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to update video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const submitDelete = async () => {
|
||||
if (!selectedRow.value?.id) return;
|
||||
submitting.value = true;
|
||||
actionError.value = null;
|
||||
try {
|
||||
await rpcClient.deleteAdminVideo({ id: selectedRow.value.id });
|
||||
deleteOpen.value = false;
|
||||
selectedRow.value = null;
|
||||
await loadVideos();
|
||||
} catch (err: any) {
|
||||
actionError.value = err?.message || "Failed to delete video";
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadVideos);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AdminSectionShell
|
||||
title="Admin Videos"
|
||||
description="Cross-user video list from admin gRPC service."
|
||||
>
|
||||
<div class="mb-4 flex justify-end">
|
||||
<AppButton size="sm" @click="actionError = null; createOpen = true">Create video</AppButton>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th class="py-3 pr-4 font-medium">ID</th>
|
||||
<th class="py-3 pr-4 font-medium">Title</th>
|
||||
<th class="py-3 pr-4 font-medium">Owner</th>
|
||||
<th class="py-3 pr-4 font-medium">Status</th>
|
||||
<th class="py-3 pr-4 font-medium">Format</th>
|
||||
<th class="py-3 pr-4 font-medium">Size</th>
|
||||
<th class="py-3 pr-4 text-right font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-if="loading" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">Loading videos...</td>
|
||||
</tr>
|
||||
<tr v-else-if="rows.length === 0" class="border-b border-gray-100">
|
||||
<td colspan="7" class="py-6 text-center text-gray-500">No videos found.</td>
|
||||
</tr>
|
||||
<tr v-for="row in rows" :key="row.id" class="border-b border-gray-100 align-top">
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.id }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.title }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.ownerEmail || row.userId }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.status }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.format || '—' }}</td>
|
||||
<td class="py-3 pr-4 text-gray-700">{{ row.size ?? 0 }}</td>
|
||||
<td class="py-3 text-right">
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton size="sm" variant="secondary" @click="openEditDialog(row)">Edit</AppButton>
|
||||
<AppButton size="sm" variant="danger" @click="openDeleteDialog(row)">Delete</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</AdminSectionShell>
|
||||
|
||||
<AppDialog v-model:visible="createOpen" title="Create admin video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="createForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="createForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="createForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="createForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="createForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="createForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="createForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="createForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="createForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canCreate" @click="submitCreate">Create</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="editOpen" title="Edit video" maxWidthClass="max-w-2xl" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Owner user ID</label>
|
||||
<AppInput v-model="editForm.userId" placeholder="user-id" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Status</label>
|
||||
<select v-model="editForm.status" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30">
|
||||
<option v-for="status in statusOptions" :key="status" :value="status">{{ status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Title</label>
|
||||
<AppInput v-model="editForm.title" placeholder="Video title" />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Video URL</label>
|
||||
<AppInput v-model="editForm.url" placeholder="https://..." />
|
||||
</div>
|
||||
<div class="space-y-2 md:col-span-2">
|
||||
<label class="text-sm font-medium text-gray-700">Description</label>
|
||||
<textarea v-model="editForm.description" rows="3" class="w-full rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground focus:border-primary/50 focus:outline-none focus:ring-2 focus:ring-primary/30" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Format</label>
|
||||
<AppInput v-model="editForm.format" placeholder="mp4" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Ad template ID</label>
|
||||
<AppInput v-model="editForm.adTemplateId" placeholder="Optional" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Size</label>
|
||||
<AppInput v-model="editForm.size" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-gray-700">Duration</label>
|
||||
<AppInput v-model="editForm.duration" type="number" placeholder="0" min="0" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton size="sm" :loading="submitting" :disabled="!canUpdate" @click="submitEdit">Save</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
|
||||
<AppDialog v-model:visible="deleteOpen" title="Delete video" maxWidthClass="max-w-md" @close="actionError = null">
|
||||
<div class="space-y-4">
|
||||
<div v-if="actionError" class="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{{ actionError }}</div>
|
||||
<p class="text-sm text-gray-700">
|
||||
Delete video <span class="font-medium">{{ selectedRow?.title || selectedRow?.id }}</span>.
|
||||
</p>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="submitting" @click="closeDialogs">Cancel</AppButton>
|
||||
<AppButton variant="danger" size="sm" :loading="submitting" @click="submitDelete">Delete</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
27
src/routes/admin/components/AdminPlaceholderTable.vue
Normal file
27
src/routes/admin/components/AdminPlaceholderTable.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
columns: string[];
|
||||
rows?: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 text-gray-500">
|
||||
<th v-for="column in columns" :key="column" class="py-3 pr-4 font-medium">
|
||||
{{ column }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in rows ?? 5" :key="i" class="border-b border-gray-100">
|
||||
<td v-for="column in columns" :key="column" class="py-3 pr-4 text-gray-700">
|
||||
—
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
18
src/routes/admin/components/AdminSectionShell.vue
Normal file
18
src/routes/admin/components/AdminSectionShell.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import PageHeader from "@/components/dashboard/PageHeader.vue";
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
description: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="space-y-4">
|
||||
<PageHeader :title="title" :description="description" />
|
||||
|
||||
<div class="rounded-2xl border border-gray-200 bg-white p-6">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -28,7 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { reactive } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -59,7 +59,7 @@ const onFormSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
client.auth.forgotPasswordCreate({ email: form.email })
|
||||
rpcClient.forgotPassword({ email: form.email })
|
||||
.then(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -20,11 +20,9 @@ const status = computed(() => String(route.query.status ?? 'error'));
|
||||
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
missing_state: 'Google login session is invalid. Please try again.',
|
||||
invalid_state: 'Google login session has expired. Please try again.',
|
||||
missing_code: 'Google did not return an authorization code.',
|
||||
access_denied: 'Google login was cancelled.',
|
||||
exchange_failed: 'Failed to sign in with Google.',
|
||||
exchange_failed: 'Failed to verify your Google sign-in. Please try again.',
|
||||
userinfo_failed: 'Failed to load your Google account information.',
|
||||
userinfo_parse_failed: 'Failed to read your Google account information.',
|
||||
missing_email: 'Your Google account did not provide an email address.',
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "vue-router";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean; requiresAdmin?: boolean };
|
||||
children?: RouteData[];
|
||||
};
|
||||
const routes: RouteData[] = [
|
||||
@@ -217,6 +217,23 @@ const routes: RouteData[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
component: () => import("./admin/Layout.vue"),
|
||||
meta: { requiresAdmin: true },
|
||||
redirect: { name: "admin-overview" },
|
||||
children: [
|
||||
{ path: "overview", name: "admin-overview", component: () => import("./admin/Overview.vue") },
|
||||
{ path: "users", name: "admin-users", component: () => import("./admin/Users.vue") },
|
||||
{ path: "videos", name: "admin-videos", component: () => import("./admin/Videos.vue") },
|
||||
{ path: "payments", name: "admin-payments", component: () => import("./admin/Payments.vue") },
|
||||
{ path: "plans", name: "admin-plans", component: () => import("./admin/Plans.vue") },
|
||||
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./admin/AdTemplates.vue") },
|
||||
{ path: "jobs", name: "admin-jobs", component: () => import("./admin/Jobs.vue") },
|
||||
{ path: "agents", name: "admin-agents", component: () => import("./admin/Agents.vue") },
|
||||
{ path: "logs", name: "admin-logs", component: () => import("./admin/Logs.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -254,6 +271,17 @@ const createAppRouter = () => {
|
||||
return { name: "login" };
|
||||
}
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAdmin)) {
|
||||
if (!auth.user) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
const role = String(auth.user.role || "").toLowerCase();
|
||||
if (role !== "admin") {
|
||||
return { name: "overview" };
|
||||
}
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
loading.finish()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -23,16 +24,8 @@ const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending
|
||||
const fetchDashboardData = async () => {
|
||||
recentVideosLoading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
|
||||
const body = response.data as any;
|
||||
|
||||
const videos = Array.isArray(body?.data?.videos)
|
||||
? body.data.videos
|
||||
: Array.isArray(body?.videos)
|
||||
? body.videos
|
||||
: [];
|
||||
|
||||
recentVideos.value = videos;
|
||||
const response = await rpcClient.listVideos({ page: 1, limit: 5 });
|
||||
recentVideos.value = response.videos ?? [];
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -112,7 +112,7 @@ const getStatusClass = (status?: string) => {
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -38,12 +38,12 @@ interface VastTemplate {
|
||||
type AdTemplateApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
vast_tag_url?: string;
|
||||
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
vastTagUrl?: string;
|
||||
adFormat?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number | null;
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
created_at?: string;
|
||||
isActive?: boolean;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
@@ -68,21 +68,21 @@ const isMutating = computed(() => saving.value || deletingId.value !== null || t
|
||||
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
|
||||
|
||||
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
|
||||
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
|
||||
id: item.id || `${item.name || 'template'}:${item.vastTagUrl || item.createdAt || ''}`,
|
||||
name: item.name || '',
|
||||
vastUrl: item.vast_tag_url || '',
|
||||
adFormat: item.ad_format || 'pre-roll',
|
||||
vastUrl: item.vastTagUrl || '',
|
||||
adFormat: item.adFormat || 'pre-roll',
|
||||
duration: typeof item.duration === 'number' ? item.duration : undefined,
|
||||
enabled: Boolean(item.is_active),
|
||||
isDefault: Boolean(item.is_default),
|
||||
createdAt: item.created_at || '',
|
||||
enabled: Boolean(item.isActive),
|
||||
isDefault: Boolean(item.isDefault),
|
||||
createdAt: item.createdAt || '',
|
||||
});
|
||||
|
||||
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'ad-templates'],
|
||||
query: async () => {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
|
||||
const response = await rpcClient.listAdTemplates();
|
||||
return (response.templates || []).map(mapTemplate);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,11 +161,12 @@ const openEditDialog = (template: VastTemplate) => {
|
||||
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
vast_tag_url: formData.value.vastUrl.trim(),
|
||||
ad_format: formData.value.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: formData.value.vastUrl.trim(),
|
||||
adFormat: formData.value.adFormat,
|
||||
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||
is_active: enabled,
|
||||
is_default: enabled ? formData.value.isDefault : false,
|
||||
isActive: enabled,
|
||||
isDefault: enabled ? formData.value.isDefault : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
@@ -213,11 +214,10 @@ const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingTemplate.value) {
|
||||
await client.adTemplates.adTemplatesUpdate(
|
||||
editingTemplate.value.id,
|
||||
buildRequestBody(editingTemplate.value.enabled),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: editingTemplate.value.id,
|
||||
...buildRequestBody(editingTemplate.value.enabled),
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||
@@ -225,7 +225,7 @@ const handleSave = async () => {
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
|
||||
await rpcClient.createAdTemplate(buildRequestBody(true));
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.createdSummary'),
|
||||
@@ -249,14 +249,16 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
|
||||
togglingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: nextValue,
|
||||
is_default: nextValue ? template.isDefault : false,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: nextValue,
|
||||
isDefault: nextValue ? template.isDefault : false,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
@@ -285,14 +287,16 @@ const handleSetDefault = async (template: VastTemplate) => {
|
||||
|
||||
defaultingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
description: '',
|
||||
vastTagUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: template.enabled,
|
||||
is_default: true,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: template.enabled,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
@@ -320,7 +324,7 @@ const handleDelete = (template: VastTemplate) => {
|
||||
accept: async () => {
|
||||
deletingId.value = template.id;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteAdTemplate({ id: template.id });
|
||||
await refetchTemplates();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { PaymentHistoryItem as PaymentHistoryApiItem, Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -19,30 +20,10 @@ import { computed, ref, watch } from 'vue';
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
|
||||
type PlansEnvelope = {
|
||||
data?: {
|
||||
plans?: ModelPlan[];
|
||||
} | ModelPlan[];
|
||||
};
|
||||
|
||||
type PaymentHistoryApiItem = {
|
||||
id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
plan_name?: string;
|
||||
invoice_id?: string;
|
||||
kind?: string;
|
||||
term_months?: number;
|
||||
payment_method?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryEnvelope = {
|
||||
data?: {
|
||||
payments?: PaymentHistoryApiItem[];
|
||||
};
|
||||
type InvoiceDownloadResponse = {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryItem = {
|
||||
@@ -69,7 +50,7 @@ const { t, i18next } = useTranslation();
|
||||
|
||||
const { data: plansResponse, isLoading } = useQuery({
|
||||
key: () => ['billing-plans'],
|
||||
query: () => client.plans.plansList({ baseUrl: '/r' }),
|
||||
query: () => rpcClient.listPlans(),
|
||||
});
|
||||
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
||||
|
||||
@@ -89,17 +70,7 @@ const purchaseTopupAmount = ref<number | null>(null);
|
||||
const purchaseLoading = ref(false);
|
||||
const purchaseError = ref<string | null>(null);
|
||||
|
||||
const plans = computed(() => {
|
||||
const body = plansResponse.value?.data as PlansEnvelope | undefined;
|
||||
const payload = body?.data;
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
|
||||
return payload.plans;
|
||||
}
|
||||
|
||||
return [] as ModelPlan[];
|
||||
});
|
||||
const plans = computed(() => plansResponse.value?.plans || [] as ModelPlan[]);
|
||||
|
||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
||||
@@ -109,11 +80,11 @@ const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
||||
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
||||
const storageLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.storage_limit || 10737418240;
|
||||
return activePlan?.storageLimit || 10737418240;
|
||||
});
|
||||
const uploadsLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.upload_limit || 50;
|
||||
return activePlan?.uploadLimit || 50;
|
||||
});
|
||||
const storagePercentage = computed(() =>
|
||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
||||
@@ -189,9 +160,9 @@ const formatPaymentMethodLabel = (value?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storageLimit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.durationLimit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.uploadLimit || 0 });
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -254,25 +225,25 @@ const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || n
|
||||
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const details: string[] = [];
|
||||
|
||||
if (item.kind !== 'wallet_topup' && item.term_months) {
|
||||
details.push(formatTermLabel(item.term_months));
|
||||
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
||||
details.push(formatTermLabel(item.termMonths));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.payment_method) {
|
||||
details.push(formatPaymentMethodLabel(item.payment_method));
|
||||
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
||||
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.expires_at) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
|
||||
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expiresAt) }));
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || '',
|
||||
date: formatHistoryDate(item.created_at),
|
||||
date: formatHistoryDate(item.createdAt),
|
||||
amount: item.amount || 0,
|
||||
plan: item.kind === 'wallet_topup'
|
||||
? t('settings.billing.walletTopup')
|
||||
: (item.plan_name || t('settings.billing.unknownPlan')),
|
||||
: (item.planName || t('settings.billing.unknownPlan')),
|
||||
status: normalizeHistoryStatus(item.status),
|
||||
invoiceId: item.invoice_id || '-',
|
||||
invoiceId: item.invoiceId || '-',
|
||||
currency: item.currency || 'USD',
|
||||
kind: item.kind || 'subscription',
|
||||
details,
|
||||
@@ -282,9 +253,8 @@ const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const loadPaymentHistory = async () => {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const response = await client.payments.historyList({ baseUrl: '/r' });
|
||||
const body = response.data as PaymentHistoryEnvelope | undefined;
|
||||
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
|
||||
const response = await rpcClient.listPaymentHistory();
|
||||
paymentHistory.value = (response.payments || []).map(mapHistoryItem);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
paymentHistory.value = [];
|
||||
@@ -308,7 +278,7 @@ const refreshBillingState = async () => {
|
||||
void loadPaymentHistory();
|
||||
|
||||
const subscriptionSummary = computed(() => {
|
||||
const expiresAt = auth.user?.plan_expires_at;
|
||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||
const formattedDate = formatHistoryDate(expiresAt);
|
||||
|
||||
if (auth.user?.plan_id) {
|
||||
@@ -434,16 +404,16 @@ const submitUpgrade = async () => {
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Record<string, any> = {
|
||||
plan_id: selectedPlan.value.id,
|
||||
term_months: selectedTermMonths.value,
|
||||
payment_method: paymentMethod,
|
||||
planId: selectedPlan.value.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod: paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
|
||||
await rpcClient.createPayment(payload);
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -481,7 +451,7 @@ const submitUpgrade = async () => {
|
||||
const handleTopup = async (amount: number) => {
|
||||
topupLoading.value = true;
|
||||
try {
|
||||
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
|
||||
await rpcClient.topupWallet({ amount });
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -517,13 +487,15 @@ const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
|
||||
const content = typeof response.data === 'string' ? response.data : '';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const response = await rpcClient.downloadInvoice({ id: item.id }) as InvoiceDownloadResponse;
|
||||
const content = response.content || '';
|
||||
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
||||
const filename = response.filename || `${item.invoiceId}.txt`;
|
||||
const blob = new Blob([content], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${item.invoiceId}.txt`;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
@@ -32,7 +32,7 @@ const handleDeleteAccount = () => {
|
||||
accept: async () => {
|
||||
deletingAccount.value = true;
|
||||
try {
|
||||
await client.me.deleteMe({ baseUrl: '/r' });
|
||||
await rpcClient.deleteMe();
|
||||
|
||||
auth.$reset();
|
||||
toast.add({
|
||||
@@ -66,7 +66,7 @@ const handleClearData = () => {
|
||||
accept: async () => {
|
||||
clearingData.value = true;
|
||||
try {
|
||||
await client.me.clearDataCreate({ baseUrl: '/r' });
|
||||
await rpcClient.clearMyData();
|
||||
|
||||
await auth.fetchMe();
|
||||
toast.add({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
@@ -64,8 +64,8 @@ const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await client.domains.domainsList({ baseUrl: '/r' });
|
||||
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).map(mapDomainItem);
|
||||
const response = await rpcClient.listDomains();
|
||||
return (response.domains || []).map(mapDomainItem);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -126,9 +126,9 @@ const handleAddDomain = async () => {
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await client.domains.domainsCreate({
|
||||
await rpcClient.createDomain({
|
||||
name: domainName,
|
||||
}, { baseUrl: '/r' });
|
||||
});
|
||||
|
||||
await refetchDomains();
|
||||
closeAddDialog();
|
||||
@@ -178,7 +178,7 @@ const handleRemoveDomain = (domain: DomainItem) => {
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetchDomains();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
@@ -90,9 +90,8 @@ const handleSave = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
await rpcClient.updatePreferences(
|
||||
toNotificationPreferencesPayload(notificationSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetchPreferences();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
@@ -100,9 +100,8 @@ const handleSave = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
await rpcClient.updatePreferences(
|
||||
toPlayerPreferencesPayload(playerSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetch();
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelPlan } from '@/api/client';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
|
||||
@@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans.sort((a,b) => a.price - b.price)"
|
||||
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -21,10 +22,9 @@ const { t } = useTranslation();
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
const response = await rpcClient.getVideo({ id: props.videoId });
|
||||
if (response.video) {
|
||||
video.value = response.video;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch video:', error);
|
||||
@@ -44,8 +44,8 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
|
||||
const shareLinks = computed(() => {
|
||||
if (!video.value) return [];
|
||||
const v = video.value;
|
||||
const playbackPath = v.url || `/play/index/${v.id}`;
|
||||
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
|
||||
const playbackPath = v.url || '';
|
||||
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}/${playbackPath.replace(/^\/+/, '')}`;
|
||||
return [
|
||||
{
|
||||
key: 'embed',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { AdTemplate as ManualAdTemplate, Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
@@ -53,9 +54,8 @@ const subtitleForm = ref({
|
||||
const fetchAdTemplates = async () => {
|
||||
loadingTemplates.value = true;
|
||||
try {
|
||||
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
|
||||
adTemplates.value = items;
|
||||
const response = await rpcClient.listAdTemplates();
|
||||
adTemplates.value = response.templates ?? [];
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch ad templates:', error);
|
||||
} finally {
|
||||
@@ -66,17 +66,15 @@ const fetchAdTemplates = async () => {
|
||||
const fetchVideo = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||
const data = (response.data as any)?.data;
|
||||
const videoData = data?.video || data;
|
||||
const adConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
const response = await rpcClient.getVideo({ id: props.videoId });
|
||||
const videoData = response.video;
|
||||
|
||||
if (videoData) {
|
||||
video.value = videoData;
|
||||
currentAdConfig.value = adConfig || null;
|
||||
currentAdConfig.value = null;
|
||||
form.value = {
|
||||
title: videoData.title || '',
|
||||
adTemplateId: adConfig?.ad_template_id || '',
|
||||
adTemplateId: '',
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -104,26 +102,25 @@ const onFormSubmit = async () => {
|
||||
if (!validate()) return;
|
||||
saving.value = true;
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
const response = await rpcClient.updateVideo({
|
||||
id: props.videoId,
|
||||
title: form.value.title,
|
||||
};
|
||||
description: video.value?.description || '',
|
||||
url: video.value?.url,
|
||||
size: video.value?.size,
|
||||
duration: video.value?.duration,
|
||||
format: video.value?.format,
|
||||
status: video.value?.status,
|
||||
});
|
||||
|
||||
if (!isFreePlan.value) {
|
||||
payload.ad_template_id = form.value.adTemplateId || '';
|
||||
}
|
||||
|
||||
const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
|
||||
|
||||
const data = (response.data as any)?.data;
|
||||
const updatedVideo = data?.video as ModelVideo | undefined;
|
||||
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||
const updatedVideo = response.video as ModelVideo | undefined;
|
||||
|
||||
if (updatedVideo) {
|
||||
video.value = updatedVideo;
|
||||
currentAdConfig.value = updatedAdConfig || null;
|
||||
currentAdConfig.value = null;
|
||||
form.value = {
|
||||
title: updatedVideo.title || '',
|
||||
adTemplateId: updatedAdConfig?.ad_template_id || '',
|
||||
adTemplateId: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
@@ -47,15 +48,15 @@ const fetchVideos = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
const response = await client.videos.videosList({
|
||||
const response = await rpcClient.listVideos({
|
||||
page: page.value,
|
||||
limit: limit.value,
|
||||
search: searchQuery.value || undefined,
|
||||
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
|
||||
} as any, { baseUrl: '/r' });
|
||||
});
|
||||
|
||||
videos.value = ((response.data as any)?.data?.videos ?? []) as ModelVideo[];
|
||||
total.value = (response.data as any)?.data?.total ?? 0;
|
||||
videos.value = response.videos ?? [];
|
||||
total.value = response.total ?? 0;
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
error.value = err?.response?.data?.message || err?.message || t('video.page.retry');
|
||||
@@ -87,7 +88,7 @@ const deleteSelectedVideos = async () => {
|
||||
selectedVideos.value
|
||||
.map(v => v.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.map(id => client.videos.videosDelete(id, { baseUrl: '/r' }))
|
||||
.map(id => rpcClient.deleteVideo({ id }))
|
||||
);
|
||||
selectedVideos.value = [];
|
||||
await fetchVideos();
|
||||
@@ -106,7 +107,7 @@ const deleteVideo = async (videoId?: string) => {
|
||||
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
|
||||
|
||||
try {
|
||||
await client.videos.videosDelete(videoId, { baseUrl: '/r' });
|
||||
await rpcClient.deleteVideo({ id: videoId });
|
||||
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||
await fetchVideos();
|
||||
} catch (err) {
|
||||
|
||||
@@ -42,7 +42,7 @@ import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import EllipsisVerticalIcon from '@/components/icons/EllipsisVerticalIcon.vue';
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { computed, nextTick, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -107,7 +107,7 @@ const handleCopyLink = async () => {
|
||||
const handleDownload = () => {
|
||||
if (props.video.id) {
|
||||
const link = document.createElement('a');
|
||||
link.href = props.video.hls_path || videoUrl.value;
|
||||
link.href = props.video.url?.startsWith('http') ? props.video.url : videoUrl.value;
|
||||
link.download = props.video.title || 'video';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import CardPopover from './CardPopover.vue';
|
||||
@@ -96,7 +96,7 @@ const toggleSelection = (video: ModelVideo) => {
|
||||
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || t('video.table.noDescription') }}
|
||||
</p>
|
||||
<div class="text-xs text-gray-400 mt-auto">
|
||||
{{ formatDate(video.created_at) }}
|
||||
{{ formatDate(video.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelVideo } from '@/api/client';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
@@ -120,7 +120,7 @@ const isSelected = (video: ModelVideo) =>
|
||||
<span class="text-sm text-gray-500">{{ formatBytes(data.size) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.created_at, true) }}</span>
|
||||
<span class="text-sm text-gray-500">{{ formatDate(data.createdAt, true) }}</span>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex items-center gap-0.5">
|
||||
|
||||
1788
src/server/gen/proto/app/v1/account.ts
Normal file
1788
src/server/gen/proto/app/v1/account.ts
Normal file
File diff suppressed because it is too large
Load Diff
7156
src/server/gen/proto/app/v1/admin.ts
Normal file
7156
src/server/gen/proto/app/v1/admin.ts
Normal file
File diff suppressed because it is too large
Load Diff
1090
src/server/gen/proto/app/v1/auth.ts
Normal file
1090
src/server/gen/proto/app/v1/auth.ts
Normal file
File diff suppressed because it is too large
Load Diff
1434
src/server/gen/proto/app/v1/catalog.ts
Normal file
1434
src/server/gen/proto/app/v1/catalog.ts
Normal file
File diff suppressed because it is too large
Load Diff
5842
src/server/gen/proto/app/v1/common.ts
Normal file
5842
src/server/gen/proto/app/v1/common.ts
Normal file
File diff suppressed because it is too large
Load Diff
902
src/server/gen/proto/app/v1/payments.ts
Normal file
902
src/server/gen/proto/app/v1/payments.ts
Normal file
@@ -0,0 +1,902 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.4
|
||||
// protoc unknown
|
||||
// source: app/v1/payments.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
import {
|
||||
type CallOptions,
|
||||
type ChannelCredentials,
|
||||
Client,
|
||||
type ClientOptions,
|
||||
type ClientUnaryCall,
|
||||
type handleUnaryCall,
|
||||
makeGenericClientConstructor,
|
||||
type Metadata,
|
||||
type ServiceError,
|
||||
type UntypedServiceImplementation,
|
||||
} from "@grpc/grpc-js";
|
||||
import { Payment, PaymentHistoryItem, PlanSubscription, WalletTransaction } from "./common";
|
||||
|
||||
export const protobufPackage = "stream.app.v1";
|
||||
|
||||
export interface CreatePaymentRequest {
|
||||
planId?: string | undefined;
|
||||
termMonths?: number | undefined;
|
||||
paymentMethod?: string | undefined;
|
||||
topupAmount?: number | undefined;
|
||||
}
|
||||
|
||||
export interface CreatePaymentResponse {
|
||||
payment?: Payment | undefined;
|
||||
subscription?: PlanSubscription | undefined;
|
||||
walletBalance?: number | undefined;
|
||||
invoiceId?: string | undefined;
|
||||
message?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ListPaymentHistoryRequest {
|
||||
}
|
||||
|
||||
export interface ListPaymentHistoryResponse {
|
||||
payments?: PaymentHistoryItem[] | undefined;
|
||||
}
|
||||
|
||||
export interface TopupWalletRequest {
|
||||
amount?: number | undefined;
|
||||
}
|
||||
|
||||
export interface TopupWalletResponse {
|
||||
walletTransaction?: WalletTransaction | undefined;
|
||||
walletBalance?: number | undefined;
|
||||
invoiceId?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DownloadInvoiceRequest {
|
||||
id?: string | undefined;
|
||||
}
|
||||
|
||||
export interface DownloadInvoiceResponse {
|
||||
filename?: string | undefined;
|
||||
contentType?: string | undefined;
|
||||
content?: string | undefined;
|
||||
}
|
||||
|
||||
function createBaseCreatePaymentRequest(): CreatePaymentRequest {
|
||||
return { planId: "", termMonths: 0, paymentMethod: "", topupAmount: undefined };
|
||||
}
|
||||
|
||||
export const CreatePaymentRequest: MessageFns<CreatePaymentRequest> = {
|
||||
encode(message: CreatePaymentRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.planId !== undefined && message.planId !== "") {
|
||||
writer.uint32(10).string(message.planId);
|
||||
}
|
||||
if (message.termMonths !== undefined && message.termMonths !== 0) {
|
||||
writer.uint32(16).int32(message.termMonths);
|
||||
}
|
||||
if (message.paymentMethod !== undefined && message.paymentMethod !== "") {
|
||||
writer.uint32(26).string(message.paymentMethod);
|
||||
}
|
||||
if (message.topupAmount !== undefined) {
|
||||
writer.uint32(33).double(message.topupAmount);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): CreatePaymentRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseCreatePaymentRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.planId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.termMonths = reader.int32();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.paymentMethod = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 33) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.topupAmount = reader.double();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): CreatePaymentRequest {
|
||||
return {
|
||||
planId: isSet(object.planId)
|
||||
? globalThis.String(object.planId)
|
||||
: isSet(object.plan_id)
|
||||
? globalThis.String(object.plan_id)
|
||||
: "",
|
||||
termMonths: isSet(object.termMonths)
|
||||
? globalThis.Number(object.termMonths)
|
||||
: isSet(object.term_months)
|
||||
? globalThis.Number(object.term_months)
|
||||
: 0,
|
||||
paymentMethod: isSet(object.paymentMethod)
|
||||
? globalThis.String(object.paymentMethod)
|
||||
: isSet(object.payment_method)
|
||||
? globalThis.String(object.payment_method)
|
||||
: "",
|
||||
topupAmount: isSet(object.topupAmount)
|
||||
? globalThis.Number(object.topupAmount)
|
||||
: isSet(object.topup_amount)
|
||||
? globalThis.Number(object.topup_amount)
|
||||
: undefined,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: CreatePaymentRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.planId !== undefined && message.planId !== "") {
|
||||
obj.planId = message.planId;
|
||||
}
|
||||
if (message.termMonths !== undefined && message.termMonths !== 0) {
|
||||
obj.termMonths = Math.round(message.termMonths);
|
||||
}
|
||||
if (message.paymentMethod !== undefined && message.paymentMethod !== "") {
|
||||
obj.paymentMethod = message.paymentMethod;
|
||||
}
|
||||
if (message.topupAmount !== undefined) {
|
||||
obj.topupAmount = message.topupAmount;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<CreatePaymentRequest>, I>>(base?: I): CreatePaymentRequest {
|
||||
return CreatePaymentRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<CreatePaymentRequest>, I>>(object: I): CreatePaymentRequest {
|
||||
const message = createBaseCreatePaymentRequest();
|
||||
message.planId = object.planId ?? "";
|
||||
message.termMonths = object.termMonths ?? 0;
|
||||
message.paymentMethod = object.paymentMethod ?? "";
|
||||
message.topupAmount = object.topupAmount ?? undefined;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseCreatePaymentResponse(): CreatePaymentResponse {
|
||||
return { payment: undefined, subscription: undefined, walletBalance: 0, invoiceId: "", message: "" };
|
||||
}
|
||||
|
||||
export const CreatePaymentResponse: MessageFns<CreatePaymentResponse> = {
|
||||
encode(message: CreatePaymentResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.payment !== undefined) {
|
||||
Payment.encode(message.payment, writer.uint32(10).fork()).join();
|
||||
}
|
||||
if (message.subscription !== undefined) {
|
||||
PlanSubscription.encode(message.subscription, writer.uint32(18).fork()).join();
|
||||
}
|
||||
if (message.walletBalance !== undefined && message.walletBalance !== 0) {
|
||||
writer.uint32(25).double(message.walletBalance);
|
||||
}
|
||||
if (message.invoiceId !== undefined && message.invoiceId !== "") {
|
||||
writer.uint32(34).string(message.invoiceId);
|
||||
}
|
||||
if (message.message !== undefined && message.message !== "") {
|
||||
writer.uint32(42).string(message.message);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): CreatePaymentResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseCreatePaymentResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.payment = Payment.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.subscription = PlanSubscription.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 25) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.walletBalance = reader.double();
|
||||
continue;
|
||||
}
|
||||
case 4: {
|
||||
if (tag !== 34) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.invoiceId = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 5: {
|
||||
if (tag !== 42) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.message = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): CreatePaymentResponse {
|
||||
return {
|
||||
payment: isSet(object.payment) ? Payment.fromJSON(object.payment) : undefined,
|
||||
subscription: isSet(object.subscription) ? PlanSubscription.fromJSON(object.subscription) : undefined,
|
||||
walletBalance: isSet(object.walletBalance)
|
||||
? globalThis.Number(object.walletBalance)
|
||||
: isSet(object.wallet_balance)
|
||||
? globalThis.Number(object.wallet_balance)
|
||||
: 0,
|
||||
invoiceId: isSet(object.invoiceId)
|
||||
? globalThis.String(object.invoiceId)
|
||||
: isSet(object.invoice_id)
|
||||
? globalThis.String(object.invoice_id)
|
||||
: "",
|
||||
message: isSet(object.message) ? globalThis.String(object.message) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: CreatePaymentResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.payment !== undefined) {
|
||||
obj.payment = Payment.toJSON(message.payment);
|
||||
}
|
||||
if (message.subscription !== undefined) {
|
||||
obj.subscription = PlanSubscription.toJSON(message.subscription);
|
||||
}
|
||||
if (message.walletBalance !== undefined && message.walletBalance !== 0) {
|
||||
obj.walletBalance = message.walletBalance;
|
||||
}
|
||||
if (message.invoiceId !== undefined && message.invoiceId !== "") {
|
||||
obj.invoiceId = message.invoiceId;
|
||||
}
|
||||
if (message.message !== undefined && message.message !== "") {
|
||||
obj.message = message.message;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<CreatePaymentResponse>, I>>(base?: I): CreatePaymentResponse {
|
||||
return CreatePaymentResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<CreatePaymentResponse>, I>>(object: I): CreatePaymentResponse {
|
||||
const message = createBaseCreatePaymentResponse();
|
||||
message.payment = (object.payment !== undefined && object.payment !== null)
|
||||
? Payment.fromPartial(object.payment)
|
||||
: undefined;
|
||||
message.subscription = (object.subscription !== undefined && object.subscription !== null)
|
||||
? PlanSubscription.fromPartial(object.subscription)
|
||||
: undefined;
|
||||
message.walletBalance = object.walletBalance ?? 0;
|
||||
message.invoiceId = object.invoiceId ?? "";
|
||||
message.message = object.message ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseListPaymentHistoryRequest(): ListPaymentHistoryRequest {
|
||||
return {};
|
||||
}
|
||||
|
||||
export const ListPaymentHistoryRequest: MessageFns<ListPaymentHistoryRequest> = {
|
||||
encode(_: ListPaymentHistoryRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ListPaymentHistoryRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseListPaymentHistoryRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(_: any): ListPaymentHistoryRequest {
|
||||
return {};
|
||||
},
|
||||
|
||||
toJSON(_: ListPaymentHistoryRequest): unknown {
|
||||
const obj: any = {};
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ListPaymentHistoryRequest>, I>>(base?: I): ListPaymentHistoryRequest {
|
||||
return ListPaymentHistoryRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ListPaymentHistoryRequest>, I>>(_: I): ListPaymentHistoryRequest {
|
||||
const message = createBaseListPaymentHistoryRequest();
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseListPaymentHistoryResponse(): ListPaymentHistoryResponse {
|
||||
return { payments: [] };
|
||||
}
|
||||
|
||||
export const ListPaymentHistoryResponse: MessageFns<ListPaymentHistoryResponse> = {
|
||||
encode(message: ListPaymentHistoryResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.payments !== undefined && message.payments.length !== 0) {
|
||||
for (const v of message.payments) {
|
||||
PaymentHistoryItem.encode(v!, writer.uint32(10).fork()).join();
|
||||
}
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): ListPaymentHistoryResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseListPaymentHistoryResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
const el = PaymentHistoryItem.decode(reader, reader.uint32());
|
||||
if (el !== undefined) {
|
||||
message.payments!.push(el);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): ListPaymentHistoryResponse {
|
||||
return {
|
||||
payments: globalThis.Array.isArray(object?.payments)
|
||||
? object.payments.map((e: any) => PaymentHistoryItem.fromJSON(e))
|
||||
: [],
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: ListPaymentHistoryResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.payments?.length) {
|
||||
obj.payments = message.payments.map((e) => PaymentHistoryItem.toJSON(e));
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<ListPaymentHistoryResponse>, I>>(base?: I): ListPaymentHistoryResponse {
|
||||
return ListPaymentHistoryResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<ListPaymentHistoryResponse>, I>>(object: I): ListPaymentHistoryResponse {
|
||||
const message = createBaseListPaymentHistoryResponse();
|
||||
message.payments = object.payments?.map((e) => PaymentHistoryItem.fromPartial(e)) || [];
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseTopupWalletRequest(): TopupWalletRequest {
|
||||
return { amount: 0 };
|
||||
}
|
||||
|
||||
export const TopupWalletRequest: MessageFns<TopupWalletRequest> = {
|
||||
encode(message: TopupWalletRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.amount !== undefined && message.amount !== 0) {
|
||||
writer.uint32(9).double(message.amount);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): TopupWalletRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseTopupWalletRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 9) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.amount = reader.double();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): TopupWalletRequest {
|
||||
return { amount: isSet(object.amount) ? globalThis.Number(object.amount) : 0 };
|
||||
},
|
||||
|
||||
toJSON(message: TopupWalletRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.amount !== undefined && message.amount !== 0) {
|
||||
obj.amount = message.amount;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<TopupWalletRequest>, I>>(base?: I): TopupWalletRequest {
|
||||
return TopupWalletRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<TopupWalletRequest>, I>>(object: I): TopupWalletRequest {
|
||||
const message = createBaseTopupWalletRequest();
|
||||
message.amount = object.amount ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseTopupWalletResponse(): TopupWalletResponse {
|
||||
return { walletTransaction: undefined, walletBalance: 0, invoiceId: "" };
|
||||
}
|
||||
|
||||
export const TopupWalletResponse: MessageFns<TopupWalletResponse> = {
|
||||
encode(message: TopupWalletResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.walletTransaction !== undefined) {
|
||||
WalletTransaction.encode(message.walletTransaction, writer.uint32(10).fork()).join();
|
||||
}
|
||||
if (message.walletBalance !== undefined && message.walletBalance !== 0) {
|
||||
writer.uint32(17).double(message.walletBalance);
|
||||
}
|
||||
if (message.invoiceId !== undefined && message.invoiceId !== "") {
|
||||
writer.uint32(26).string(message.invoiceId);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): TopupWalletResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseTopupWalletResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.walletTransaction = WalletTransaction.decode(reader, reader.uint32());
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 17) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.walletBalance = reader.double();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.invoiceId = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): TopupWalletResponse {
|
||||
return {
|
||||
walletTransaction: isSet(object.walletTransaction)
|
||||
? WalletTransaction.fromJSON(object.walletTransaction)
|
||||
: isSet(object.wallet_transaction)
|
||||
? WalletTransaction.fromJSON(object.wallet_transaction)
|
||||
: undefined,
|
||||
walletBalance: isSet(object.walletBalance)
|
||||
? globalThis.Number(object.walletBalance)
|
||||
: isSet(object.wallet_balance)
|
||||
? globalThis.Number(object.wallet_balance)
|
||||
: 0,
|
||||
invoiceId: isSet(object.invoiceId)
|
||||
? globalThis.String(object.invoiceId)
|
||||
: isSet(object.invoice_id)
|
||||
? globalThis.String(object.invoice_id)
|
||||
: "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: TopupWalletResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.walletTransaction !== undefined) {
|
||||
obj.walletTransaction = WalletTransaction.toJSON(message.walletTransaction);
|
||||
}
|
||||
if (message.walletBalance !== undefined && message.walletBalance !== 0) {
|
||||
obj.walletBalance = message.walletBalance;
|
||||
}
|
||||
if (message.invoiceId !== undefined && message.invoiceId !== "") {
|
||||
obj.invoiceId = message.invoiceId;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<TopupWalletResponse>, I>>(base?: I): TopupWalletResponse {
|
||||
return TopupWalletResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<TopupWalletResponse>, I>>(object: I): TopupWalletResponse {
|
||||
const message = createBaseTopupWalletResponse();
|
||||
message.walletTransaction = (object.walletTransaction !== undefined && object.walletTransaction !== null)
|
||||
? WalletTransaction.fromPartial(object.walletTransaction)
|
||||
: undefined;
|
||||
message.walletBalance = object.walletBalance ?? 0;
|
||||
message.invoiceId = object.invoiceId ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseDownloadInvoiceRequest(): DownloadInvoiceRequest {
|
||||
return { id: "" };
|
||||
}
|
||||
|
||||
export const DownloadInvoiceRequest: MessageFns<DownloadInvoiceRequest> = {
|
||||
encode(message: DownloadInvoiceRequest, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.id !== undefined && message.id !== "") {
|
||||
writer.uint32(10).string(message.id);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): DownloadInvoiceRequest {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseDownloadInvoiceRequest();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.id = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): DownloadInvoiceRequest {
|
||||
return { id: isSet(object.id) ? globalThis.String(object.id) : "" };
|
||||
},
|
||||
|
||||
toJSON(message: DownloadInvoiceRequest): unknown {
|
||||
const obj: any = {};
|
||||
if (message.id !== undefined && message.id !== "") {
|
||||
obj.id = message.id;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DownloadInvoiceRequest>, I>>(base?: I): DownloadInvoiceRequest {
|
||||
return DownloadInvoiceRequest.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<DownloadInvoiceRequest>, I>>(object: I): DownloadInvoiceRequest {
|
||||
const message = createBaseDownloadInvoiceRequest();
|
||||
message.id = object.id ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
function createBaseDownloadInvoiceResponse(): DownloadInvoiceResponse {
|
||||
return { filename: "", contentType: "", content: "" };
|
||||
}
|
||||
|
||||
export const DownloadInvoiceResponse: MessageFns<DownloadInvoiceResponse> = {
|
||||
encode(message: DownloadInvoiceResponse, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.filename !== undefined && message.filename !== "") {
|
||||
writer.uint32(10).string(message.filename);
|
||||
}
|
||||
if (message.contentType !== undefined && message.contentType !== "") {
|
||||
writer.uint32(18).string(message.contentType);
|
||||
}
|
||||
if (message.content !== undefined && message.content !== "") {
|
||||
writer.uint32(26).string(message.content);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): DownloadInvoiceResponse {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseDownloadInvoiceResponse();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 10) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.filename = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 18) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.contentType = reader.string();
|
||||
continue;
|
||||
}
|
||||
case 3: {
|
||||
if (tag !== 26) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.content = reader.string();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): DownloadInvoiceResponse {
|
||||
return {
|
||||
filename: isSet(object.filename) ? globalThis.String(object.filename) : "",
|
||||
contentType: isSet(object.contentType)
|
||||
? globalThis.String(object.contentType)
|
||||
: isSet(object.content_type)
|
||||
? globalThis.String(object.content_type)
|
||||
: "",
|
||||
content: isSet(object.content) ? globalThis.String(object.content) : "",
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: DownloadInvoiceResponse): unknown {
|
||||
const obj: any = {};
|
||||
if (message.filename !== undefined && message.filename !== "") {
|
||||
obj.filename = message.filename;
|
||||
}
|
||||
if (message.contentType !== undefined && message.contentType !== "") {
|
||||
obj.contentType = message.contentType;
|
||||
}
|
||||
if (message.content !== undefined && message.content !== "") {
|
||||
obj.content = message.content;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<DownloadInvoiceResponse>, I>>(base?: I): DownloadInvoiceResponse {
|
||||
return DownloadInvoiceResponse.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<DownloadInvoiceResponse>, I>>(object: I): DownloadInvoiceResponse {
|
||||
const message = createBaseDownloadInvoiceResponse();
|
||||
message.filename = object.filename ?? "";
|
||||
message.contentType = object.contentType ?? "";
|
||||
message.content = object.content ?? "";
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
export type PaymentsServiceService = typeof PaymentsServiceService;
|
||||
export const PaymentsServiceService = {
|
||||
createPayment: {
|
||||
path: "/stream.app.v1.PaymentsService/CreatePayment",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: CreatePaymentRequest): Buffer => Buffer.from(CreatePaymentRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): CreatePaymentRequest => CreatePaymentRequest.decode(value),
|
||||
responseSerialize: (value: CreatePaymentResponse): Buffer =>
|
||||
Buffer.from(CreatePaymentResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): CreatePaymentResponse => CreatePaymentResponse.decode(value),
|
||||
},
|
||||
listPaymentHistory: {
|
||||
path: "/stream.app.v1.PaymentsService/ListPaymentHistory",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: ListPaymentHistoryRequest): Buffer =>
|
||||
Buffer.from(ListPaymentHistoryRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): ListPaymentHistoryRequest => ListPaymentHistoryRequest.decode(value),
|
||||
responseSerialize: (value: ListPaymentHistoryResponse): Buffer =>
|
||||
Buffer.from(ListPaymentHistoryResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): ListPaymentHistoryResponse => ListPaymentHistoryResponse.decode(value),
|
||||
},
|
||||
topupWallet: {
|
||||
path: "/stream.app.v1.PaymentsService/TopupWallet",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: TopupWalletRequest): Buffer => Buffer.from(TopupWalletRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): TopupWalletRequest => TopupWalletRequest.decode(value),
|
||||
responseSerialize: (value: TopupWalletResponse): Buffer => Buffer.from(TopupWalletResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): TopupWalletResponse => TopupWalletResponse.decode(value),
|
||||
},
|
||||
downloadInvoice: {
|
||||
path: "/stream.app.v1.PaymentsService/DownloadInvoice",
|
||||
requestStream: false,
|
||||
responseStream: false,
|
||||
requestSerialize: (value: DownloadInvoiceRequest): Buffer =>
|
||||
Buffer.from(DownloadInvoiceRequest.encode(value).finish()),
|
||||
requestDeserialize: (value: Buffer): DownloadInvoiceRequest => DownloadInvoiceRequest.decode(value),
|
||||
responseSerialize: (value: DownloadInvoiceResponse): Buffer =>
|
||||
Buffer.from(DownloadInvoiceResponse.encode(value).finish()),
|
||||
responseDeserialize: (value: Buffer): DownloadInvoiceResponse => DownloadInvoiceResponse.decode(value),
|
||||
},
|
||||
} as const;
|
||||
|
||||
export interface PaymentsServiceServer extends UntypedServiceImplementation {
|
||||
createPayment: handleUnaryCall<CreatePaymentRequest, CreatePaymentResponse>;
|
||||
listPaymentHistory: handleUnaryCall<ListPaymentHistoryRequest, ListPaymentHistoryResponse>;
|
||||
topupWallet: handleUnaryCall<TopupWalletRequest, TopupWalletResponse>;
|
||||
downloadInvoice: handleUnaryCall<DownloadInvoiceRequest, DownloadInvoiceResponse>;
|
||||
}
|
||||
|
||||
export interface PaymentsServiceClient extends Client {
|
||||
createPayment(
|
||||
request: CreatePaymentRequest,
|
||||
callback: (error: ServiceError | null, response: CreatePaymentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
createPayment(
|
||||
request: CreatePaymentRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: CreatePaymentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
createPayment(
|
||||
request: CreatePaymentRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: CreatePaymentResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
listPaymentHistory(
|
||||
request: ListPaymentHistoryRequest,
|
||||
callback: (error: ServiceError | null, response: ListPaymentHistoryResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
listPaymentHistory(
|
||||
request: ListPaymentHistoryRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: ListPaymentHistoryResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
listPaymentHistory(
|
||||
request: ListPaymentHistoryRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: ListPaymentHistoryResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
topupWallet(
|
||||
request: TopupWalletRequest,
|
||||
callback: (error: ServiceError | null, response: TopupWalletResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
topupWallet(
|
||||
request: TopupWalletRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: TopupWalletResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
topupWallet(
|
||||
request: TopupWalletRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: TopupWalletResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
downloadInvoice(
|
||||
request: DownloadInvoiceRequest,
|
||||
callback: (error: ServiceError | null, response: DownloadInvoiceResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
downloadInvoice(
|
||||
request: DownloadInvoiceRequest,
|
||||
metadata: Metadata,
|
||||
callback: (error: ServiceError | null, response: DownloadInvoiceResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
downloadInvoice(
|
||||
request: DownloadInvoiceRequest,
|
||||
metadata: Metadata,
|
||||
options: Partial<CallOptions>,
|
||||
callback: (error: ServiceError | null, response: DownloadInvoiceResponse) => void,
|
||||
): ClientUnaryCall;
|
||||
}
|
||||
|
||||
export const PaymentsServiceClient = makeGenericClientConstructor(
|
||||
PaymentsServiceService,
|
||||
"stream.app.v1.PaymentsService",
|
||||
) as unknown as {
|
||||
new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): PaymentsServiceClient;
|
||||
service: typeof PaymentsServiceService;
|
||||
serviceName: string;
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
export type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
export interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
1283
src/server/gen/proto/app/v1/videos.ts
Normal file
1283
src/server/gen/proto/app/v1/videos.ts
Normal file
File diff suppressed because it is too large
Load Diff
231
src/server/gen/proto/google/protobuf/timestamp.ts
Normal file
231
src/server/gen/proto/google/protobuf/timestamp.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// Code generated by protoc-gen-ts_proto. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-ts_proto v2.11.4
|
||||
// protoc unknown
|
||||
// source: google/protobuf/timestamp.proto
|
||||
|
||||
/* eslint-disable */
|
||||
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire";
|
||||
|
||||
export const protobufPackage = "google.protobuf";
|
||||
|
||||
/**
|
||||
* A Timestamp represents a point in time independent of any time zone or local
|
||||
* calendar, encoded as a count of seconds and fractions of seconds at
|
||||
* nanosecond resolution. The count is relative to an epoch at UTC midnight on
|
||||
* January 1, 1970, in the proleptic Gregorian calendar which extends the
|
||||
* Gregorian calendar backwards to year one.
|
||||
*
|
||||
* All minutes are 60 seconds long. Leap seconds are "smeared" so that no leap
|
||||
* second table is needed for interpretation, using a [24-hour linear
|
||||
* smear](https://developers.google.com/time/smear).
|
||||
*
|
||||
* The range is from 0001-01-01T00:00:00Z to 9999-12-31T23:59:59.999999999Z. By
|
||||
* restricting to that range, we ensure that we can convert to and from [RFC
|
||||
* 3339](https://www.ietf.org/rfc/rfc3339.txt) date strings.
|
||||
*
|
||||
* # Examples
|
||||
*
|
||||
* Example 1: Compute Timestamp from POSIX `time()`.
|
||||
*
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds(time(NULL));
|
||||
* timestamp.set_nanos(0);
|
||||
*
|
||||
* Example 2: Compute Timestamp from POSIX `gettimeofday()`.
|
||||
*
|
||||
* struct timeval tv;
|
||||
* gettimeofday(&tv, NULL);
|
||||
*
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds(tv.tv_sec);
|
||||
* timestamp.set_nanos(tv.tv_usec * 1000);
|
||||
*
|
||||
* Example 3: Compute Timestamp from Win32 `GetSystemTimeAsFileTime()`.
|
||||
*
|
||||
* FILETIME ft;
|
||||
* GetSystemTimeAsFileTime(&ft);
|
||||
* UINT64 ticks = (((UINT64)ft.dwHighDateTime) << 32) | ft.dwLowDateTime;
|
||||
*
|
||||
* // A Windows tick is 100 nanoseconds. Windows epoch 1601-01-01T00:00:00Z
|
||||
* // is 11644473600 seconds before Unix epoch 1970-01-01T00:00:00Z.
|
||||
* Timestamp timestamp;
|
||||
* timestamp.set_seconds((INT64) ((ticks / 10000000) - 11644473600LL));
|
||||
* timestamp.set_nanos((INT32) ((ticks % 10000000) * 100));
|
||||
*
|
||||
* Example 4: Compute Timestamp from Java `System.currentTimeMillis()`.
|
||||
*
|
||||
* long millis = System.currentTimeMillis();
|
||||
*
|
||||
* Timestamp timestamp = Timestamp.newBuilder().setSeconds(millis / 1000)
|
||||
* .setNanos((int) ((millis % 1000) * 1000000)).build();
|
||||
*
|
||||
* Example 5: Compute Timestamp from Java `Instant.now()`.
|
||||
*
|
||||
* Instant now = Instant.now();
|
||||
*
|
||||
* Timestamp timestamp =
|
||||
* Timestamp.newBuilder().setSeconds(now.getEpochSecond())
|
||||
* .setNanos(now.getNano()).build();
|
||||
*
|
||||
* Example 6: Compute Timestamp from current time in Python.
|
||||
*
|
||||
* timestamp = Timestamp()
|
||||
* timestamp.GetCurrentTime()
|
||||
*
|
||||
* # JSON Mapping
|
||||
*
|
||||
* In JSON format, the Timestamp type is encoded as a string in the
|
||||
* [RFC 3339](https://www.ietf.org/rfc/rfc3339.txt) format. That is, the
|
||||
* format is "{year}-{month}-{day}T{hour}:{min}:{sec}[.{frac_sec}]Z"
|
||||
* where {year} is always expressed using four digits while {month}, {day},
|
||||
* {hour}, {min}, and {sec} are zero-padded to two digits each. The fractional
|
||||
* seconds, which can go up to 9 digits (i.e. up to 1 nanosecond resolution),
|
||||
* are optional. The "Z" suffix indicates the timezone ("UTC"); the timezone
|
||||
* is required. A proto3 JSON serializer should always use UTC (as indicated by
|
||||
* "Z") when printing the Timestamp type and a proto3 JSON parser should be
|
||||
* able to accept both UTC and other timezones (as indicated by an offset).
|
||||
*
|
||||
* For example, "2017-01-15T01:30:15.01Z" encodes 15.01 seconds past
|
||||
* 01:30 UTC on January 15, 2017.
|
||||
*
|
||||
* In JavaScript, one can convert a Date object to this format using the
|
||||
* standard
|
||||
* [toISOString()](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString)
|
||||
* method. In Python, a standard `datetime.datetime` object can be converted
|
||||
* to this format using
|
||||
* [`strftime`](https://docs.python.org/2/library/time.html#time.strftime) with
|
||||
* the time format spec '%Y-%m-%dT%H:%M:%S.%fZ'. Likewise, in Java, one can use
|
||||
* the Joda Time's [`ISODateTimeFormat.dateTime()`](
|
||||
* http://joda-time.sourceforge.net/apidocs/org/joda/time/format/ISODateTimeFormat.html#dateTime()
|
||||
* ) to obtain a formatter capable of generating timestamps in this format.
|
||||
*/
|
||||
export interface Timestamp {
|
||||
/**
|
||||
* Represents seconds of UTC time since Unix epoch
|
||||
* 1970-01-01T00:00:00Z. Must be from 0001-01-01T00:00:00Z to
|
||||
* 9999-12-31T23:59:59Z inclusive.
|
||||
*/
|
||||
seconds?:
|
||||
| number
|
||||
| undefined;
|
||||
/**
|
||||
* Non-negative fractions of a second at nanosecond resolution. Negative
|
||||
* second values with fractions must still have non-negative nanos values
|
||||
* that count forward in time. Must be from 0 to 999,999,999
|
||||
* inclusive.
|
||||
*/
|
||||
nanos?: number | undefined;
|
||||
}
|
||||
|
||||
function createBaseTimestamp(): Timestamp {
|
||||
return { seconds: 0, nanos: 0 };
|
||||
}
|
||||
|
||||
export const Timestamp: MessageFns<Timestamp> = {
|
||||
encode(message: Timestamp, writer: BinaryWriter = new BinaryWriter()): BinaryWriter {
|
||||
if (message.seconds !== undefined && message.seconds !== 0) {
|
||||
writer.uint32(8).int64(message.seconds);
|
||||
}
|
||||
if (message.nanos !== undefined && message.nanos !== 0) {
|
||||
writer.uint32(16).int32(message.nanos);
|
||||
}
|
||||
return writer;
|
||||
},
|
||||
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): Timestamp {
|
||||
const reader = input instanceof BinaryReader ? input : new BinaryReader(input);
|
||||
const end = length === undefined ? reader.len : reader.pos + length;
|
||||
const message = createBaseTimestamp();
|
||||
while (reader.pos < end) {
|
||||
const tag = reader.uint32();
|
||||
switch (tag >>> 3) {
|
||||
case 1: {
|
||||
if (tag !== 8) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.seconds = longToNumber(reader.int64());
|
||||
continue;
|
||||
}
|
||||
case 2: {
|
||||
if (tag !== 16) {
|
||||
break;
|
||||
}
|
||||
|
||||
message.nanos = reader.int32();
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if ((tag & 7) === 4 || tag === 0) {
|
||||
break;
|
||||
}
|
||||
reader.skip(tag & 7);
|
||||
}
|
||||
return message;
|
||||
},
|
||||
|
||||
fromJSON(object: any): Timestamp {
|
||||
return {
|
||||
seconds: isSet(object.seconds) ? globalThis.Number(object.seconds) : 0,
|
||||
nanos: isSet(object.nanos) ? globalThis.Number(object.nanos) : 0,
|
||||
};
|
||||
},
|
||||
|
||||
toJSON(message: Timestamp): unknown {
|
||||
const obj: any = {};
|
||||
if (message.seconds !== undefined && message.seconds !== 0) {
|
||||
obj.seconds = Math.round(message.seconds);
|
||||
}
|
||||
if (message.nanos !== undefined && message.nanos !== 0) {
|
||||
obj.nanos = Math.round(message.nanos);
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
|
||||
create<I extends Exact<DeepPartial<Timestamp>, I>>(base?: I): Timestamp {
|
||||
return Timestamp.fromPartial(base ?? ({} as any));
|
||||
},
|
||||
fromPartial<I extends Exact<DeepPartial<Timestamp>, I>>(object: I): Timestamp {
|
||||
const message = createBaseTimestamp();
|
||||
message.seconds = object.seconds ?? 0;
|
||||
message.nanos = object.nanos ?? 0;
|
||||
return message;
|
||||
},
|
||||
};
|
||||
|
||||
type Builtin = Date | Function | Uint8Array | string | number | boolean | undefined;
|
||||
|
||||
export type DeepPartial<T> = T extends Builtin ? T
|
||||
: T extends globalThis.Array<infer U> ? globalThis.Array<DeepPartial<U>>
|
||||
: T extends ReadonlyArray<infer U> ? ReadonlyArray<DeepPartial<U>>
|
||||
: T extends {} ? { [K in keyof T]?: DeepPartial<T[K]> }
|
||||
: Partial<T>;
|
||||
|
||||
type KeysOfUnion<T> = T extends T ? keyof T : never;
|
||||
export type Exact<P, I extends P> = P extends Builtin ? P
|
||||
: P & { [K in keyof P]: Exact<P[K], I[K]> } & { [K in Exclude<keyof I, KeysOfUnion<P>>]: never };
|
||||
|
||||
function longToNumber(int64: { toString(): string }): number {
|
||||
const num = globalThis.Number(int64.toString());
|
||||
if (num > globalThis.Number.MAX_SAFE_INTEGER) {
|
||||
throw new globalThis.Error("Value is larger than Number.MAX_SAFE_INTEGER");
|
||||
}
|
||||
if (num < globalThis.Number.MIN_SAFE_INTEGER) {
|
||||
throw new globalThis.Error("Value is smaller than Number.MIN_SAFE_INTEGER");
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
function isSet(value: any): boolean {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
export interface MessageFns<T> {
|
||||
encode(message: T, writer?: BinaryWriter): BinaryWriter;
|
||||
decode(input: BinaryReader | Uint8Array, length?: number): T;
|
||||
fromJSON(object: any): T;
|
||||
toJSON(message: T): unknown;
|
||||
create<I extends Exact<DeepPartial<T>, I>>(base?: I): T;
|
||||
fromPartial<I extends Exact<DeepPartial<T>, I>>(object: I): T;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MiddlewareHandler } from "hono";
|
||||
import { getCookie } from "hono/cookie";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { getUserServiceClient } from "../services/grpcClient";
|
||||
import { getAccountServiceClient } from "../services/grpcClient";
|
||||
import { generateAndSetTokens } from "../utils";
|
||||
export const authenticate: MiddlewareHandler = async (ctx, next) => {
|
||||
let payload
|
||||
@@ -35,14 +35,17 @@ export const authenticate: MiddlewareHandler = async (ctx, next) => {
|
||||
if (!userId) {
|
||||
throw new HTTPException(401)
|
||||
}
|
||||
const userData = await getUserServiceClient().getUser({ id: userId });
|
||||
// userData.user
|
||||
const userData = await getAccountServiceClient().getMe({});
|
||||
const user = userData.user;
|
||||
redis.del("refresh_uuid:" + refreshUuid);
|
||||
const tokenPair = await generateAndSetTokens(ctx, userData.user!);
|
||||
if (!user?.id || !user?.role || user.id !== userId) {
|
||||
throw new HTTPException(401)
|
||||
}
|
||||
payload = {
|
||||
user_id: userId,
|
||||
email: userData.user!.email,
|
||||
role: userData.user!.role,
|
||||
user_id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token_id: tokenPair.accessUUID,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,14 @@ declare module "hono" {
|
||||
isMobile: boolean;
|
||||
redis: RedisClient;
|
||||
jwtProvider: JwtProvider;
|
||||
jwtPayload: Record<string, unknown>;
|
||||
userId: string;
|
||||
role: string;
|
||||
email: string;
|
||||
}
|
||||
}
|
||||
|
||||
const client = new RedisClient("redis://:pass123@47.84.62.226:6379/3");
|
||||
const redisClient = new RedisClient("redis://:pass123@47.84.62.226:6379/3");
|
||||
|
||||
export function setupMiddlewares(app: Hono) {
|
||||
app.use(
|
||||
@@ -52,14 +54,11 @@ export function setupMiddlewares(app: Hono) {
|
||||
await next();
|
||||
});
|
||||
app.use(async (c, next) => {
|
||||
return await client
|
||||
.connect()
|
||||
.then(() => {
|
||||
c.set("redis", client);
|
||||
return next();
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error("Failed to connect to Redis", e);
|
||||
});
|
||||
try {
|
||||
return await redisClient.connect().then(() => c.set("redis", redisClient)).then(next)
|
||||
} catch (e) {
|
||||
console.error("Failed to connect to Redis", e);
|
||||
return c.json({ error: "Redis unavailable" }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,232 +0,0 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
import { AwsClient } from 'aws4fetch';
|
||||
|
||||
export type Part = {
|
||||
index: number
|
||||
host: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type Manifest = {
|
||||
version: 1
|
||||
id: string
|
||||
filename: string
|
||||
total_parts: number
|
||||
parts: Part[]
|
||||
createdAt: number
|
||||
expiresAt: number
|
||||
size: number
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 Config
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const S3_ENDPOINT = "https://minio1.webtui.vn:9000"
|
||||
const BUCKET_NAME = "bucket-lethdat"
|
||||
|
||||
const aws = new AwsClient({
|
||||
accessKeyId: "lethdat",
|
||||
secretAccessKey: "D@tkhong9",
|
||||
service: 's3',
|
||||
region: 'auto'
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// S3 Operations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const OBJECT_KEY = (id: string) => `${id}.json`
|
||||
|
||||
/** Persist a manifest as JSON in MinIO. */
|
||||
export async function saveManifest(manifest: Manifest): Promise<void> {
|
||||
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(manifest.id)}`;
|
||||
|
||||
const response = await aws.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(manifest),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save manifest: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch a manifest from MinIO. */
|
||||
export async function getManifest(id: string): Promise<Manifest | null> {
|
||||
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(id)}`;
|
||||
|
||||
try {
|
||||
const response = await aws.fetch(url, {
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get manifest: ${response.status}`)
|
||||
}
|
||||
|
||||
const text = await response.text()
|
||||
const manifest: Manifest = JSON.parse(text)
|
||||
|
||||
if (manifest.expiresAt < Date.now()) {
|
||||
await deleteManifest(id).catch(() => { })
|
||||
return null
|
||||
}
|
||||
|
||||
return manifest
|
||||
} catch (error) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a manifest object from MinIO. */
|
||||
export async function deleteManifest(id: string): Promise<void> {
|
||||
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${OBJECT_KEY(id)}`;
|
||||
|
||||
const response = await aws.fetch(url, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (!response.ok && response.status !== 404) {
|
||||
throw new Error(`Failed to delete manifest: ${response.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Allowed chunk source hosts
|
||||
const ALLOWED_HOSTS = [
|
||||
'tmpfiles.org',
|
||||
'gofile.io',
|
||||
'pixeldrain.com',
|
||||
'uploadfiles.io',
|
||||
'anonfiles.com',
|
||||
]
|
||||
|
||||
/** Returns an error message if any URL is disallowed, otherwise null. */
|
||||
export function validateChunkUrls(chunks: string[]): string | null {
|
||||
for (const u of chunks) {
|
||||
try {
|
||||
const { hostname } = new URL(u)
|
||||
if (!ALLOWED_HOSTS.some(h => hostname.includes(h))) {
|
||||
return `host not allowed: ${hostname}`
|
||||
}
|
||||
} catch {
|
||||
return `invalid url: ${u}`
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function sanitizeFilename(name: string): string {
|
||||
return name.replace(/[^a-zA-Z0-9._-]/g, '_')
|
||||
}
|
||||
|
||||
export function detectHost(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '')
|
||||
} catch {
|
||||
return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
function formatUrl(url: string): string {
|
||||
if (url.includes("tmpfiles.org/") && !url.includes("tmpfiles.org/dl/")) {
|
||||
return url.trim().replace("tmpfiles.org/", 'tmpfiles.org/dl/')
|
||||
}
|
||||
return url.trim()
|
||||
}
|
||||
|
||||
/** List all manifests in bucket (simple implementation). */
|
||||
export async function getListFiles(): Promise<string[]> {
|
||||
// For now return empty array - implement listing if needed
|
||||
// MinIO S3 ListObjectsV2 would require XML parsing
|
||||
return []
|
||||
}
|
||||
|
||||
/** Build a new Manifest. */
|
||||
export function createManifest(
|
||||
filename: string,
|
||||
chunks: string[],
|
||||
size: number,
|
||||
ttlMs = 60 * 60 * 1000,
|
||||
): Manifest {
|
||||
const id = crypto.randomUUID()
|
||||
const now = Date.now()
|
||||
return {
|
||||
version: 1,
|
||||
id,
|
||||
filename: sanitizeFilename(filename),
|
||||
total_parts: chunks.length,
|
||||
parts: chunks.map((url, index) => ({ index, host: detectHost(url), url: formatUrl(url) })),
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
size,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Streaming
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Streams all parts in index order as one continuous ReadableStream. */
|
||||
export function streamManifest(manifest: Manifest): ReadableStream<Uint8Array> {
|
||||
const parts = [...manifest.parts].sort((a, b) => a.index - b.index)
|
||||
const RETRY = 3
|
||||
return new ReadableStream({
|
||||
async start(controller) {
|
||||
for (const part of parts) {
|
||||
let attempt = 0
|
||||
let ok = false
|
||||
while (attempt < RETRY && !ok) {
|
||||
attempt++
|
||||
try {
|
||||
const res = await fetch(formatUrl(part.url))
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const reader = res.body!.getReader()
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
controller.enqueue(value)
|
||||
}
|
||||
ok = true
|
||||
} catch (err: any) {
|
||||
if (attempt >= RETRY) {
|
||||
controller.error(new Error(`Part ${part.index} failed: ${err?.message ?? err}`))
|
||||
return
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 1000 * attempt))
|
||||
}
|
||||
}
|
||||
}
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
}
|
||||
export async function saveImageFromStream(stream: ArrayBuffer, filename: string): Promise<void> {
|
||||
// Implement this function to save the thumbnail image stream to storage and update the database with the thumbnail URL
|
||||
const url = `${S3_ENDPOINT}/${BUCKET_NAME}/${filename}.jpg`;
|
||||
|
||||
const response = await aws.fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'image/jpeg',
|
||||
},
|
||||
body: stream,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save thumbnail: ${response.status} ${await response.text()}`)
|
||||
}
|
||||
}
|
||||
@@ -1,139 +1,122 @@
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import { Hono } from "hono";
|
||||
import z, { success } from "zod";
|
||||
import { getUserServiceClient } from "../services/grpcClient";
|
||||
import { generateAndSetTokens } from "../utils";
|
||||
import { getCookie, setCookie } from "hono/cookie";
|
||||
import { jwt } from "hono/jwt";
|
||||
import { authenticate } from "../middlewares/authenticate";
|
||||
// authGroup := r.Group("/auth")
|
||||
// {
|
||||
// authGroup.POST("/login", authHandler.Login)
|
||||
// authGroup.POST("/register", authHandler.Register)
|
||||
// authGroup.POST("/forgot-password", authHandler.ForgotPassword)
|
||||
// authGroup.POST("/reset-password", authHandler.ResetPassword)
|
||||
// authGroup.GET("/google/login", authHandler.LoginGoogle)
|
||||
// authGroup.GET("/google/callback", authHandler.GoogleCallback)
|
||||
// }
|
||||
import { Context, Hono } from "hono";
|
||||
import { deleteCookie } from "hono/cookie";
|
||||
import { HTTPException } from "hono/http-exception";
|
||||
import { getAuthServiceClient, getInternalGrpcMetadata } from "../services/grpcClient";
|
||||
import type { User } from "@/server/gen/proto/app/v1/common";
|
||||
|
||||
const authRoute = new Hono();
|
||||
authRoute.post(
|
||||
"/login",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
email: z.email("Invalid email or password"),
|
||||
password: z.string().min(6, "Invalid email or password"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { email, password } = c.req.valid("json");
|
||||
const user = await getUserServiceClient().getUserByEmail({ email });
|
||||
if (!user) {
|
||||
return c.json({ error: "Invalid email or password" }, 401);
|
||||
}
|
||||
const isMatch = Bun.password.verifySync(password, user.user!.password!, "bcrypt");
|
||||
if (!isMatch) {
|
||||
return c.json({ error: "Invalid email or password" }, 401);
|
||||
}
|
||||
await generateAndSetTokens(c, user.user!);
|
||||
return c.json({ message: "Login successful" });
|
||||
},
|
||||
);
|
||||
authRoute.post(
|
||||
"/register",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({
|
||||
email: z.email("Invalid email"),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
}),
|
||||
),
|
||||
async (c) => {
|
||||
const { email, username, password } = c.req.valid("json");
|
||||
const user = await getUserServiceClient().createUser({
|
||||
email,
|
||||
username,
|
||||
password: Bun.password.hashSync(password, { algorithm: "bcrypt", cost: 12 }),
|
||||
});
|
||||
delete user.user?.password;
|
||||
return c.json({ success: true, user: user.user });
|
||||
},
|
||||
);
|
||||
authRoute.post(
|
||||
"/forgot-password",
|
||||
zValidator("json", z.object({ email: z.email("Invalid email") })),
|
||||
async (c) => {
|
||||
const { email } = c.req.valid("json");
|
||||
const user = await getUserServiceClient().getUserByEmail({ email });
|
||||
if (user) {
|
||||
const redis = c.get("redis");
|
||||
const resetToken = crypto.randomUUID();
|
||||
redis?.set("reset_pw:" + resetToken, user.user?.id || "", "EX", 15 * 60);
|
||||
//TODO: Connect to email service to send reset link with token
|
||||
}
|
||||
return c.json({ message: "If email exists, a reset link has been sent" });
|
||||
},
|
||||
);
|
||||
authRoute.post(
|
||||
"/reset-password",
|
||||
zValidator(
|
||||
"json",
|
||||
z.object({ token: z.string(), password: z.string().min(6) }),
|
||||
),
|
||||
async (c) => {
|
||||
const { token, password } = c.req.valid("json");
|
||||
const redis = c.get("redis");
|
||||
const userId = await redis?.get("reset_pw:" + token);
|
||||
if (userId) {
|
||||
// Update the user's password in the database
|
||||
await getUserServiceClient().updateUserPassword({
|
||||
id: userId,
|
||||
newPassword: Bun.password.hashSync(password, {
|
||||
algorithm: "bcrypt",
|
||||
cost: 12,
|
||||
}),
|
||||
});
|
||||
}
|
||||
return c.json({ message: "Reset Password endpoint" });
|
||||
},
|
||||
);
|
||||
authRoute.get(
|
||||
"/google/login",
|
||||
zValidator("query", z.object({ redirect_uri: z.string().url() })),
|
||||
async (c) => {
|
||||
//TODO: Implement Google OAuth flow
|
||||
return c.json({ message: "Google Login endpoint" });
|
||||
},
|
||||
);
|
||||
authRoute.get(
|
||||
"/google/callback",
|
||||
zValidator("query", z.object({ code: z.string(), state: z.string() })),
|
||||
async (c) => {
|
||||
//TODO: Implement Google OAuth flow
|
||||
return c.json({ message: "Google Callback endpoint" });
|
||||
},
|
||||
);
|
||||
|
||||
const defaultGoogleFinalizePath = "/auth/google/finalize";
|
||||
|
||||
export const ensureSessionUser = (user: User | null | undefined) => {
|
||||
if (!user?.id || !user.email) {
|
||||
throw new HTTPException(500, { message: "Invalid auth user payload" });
|
||||
}
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role ?? "USER",
|
||||
username: user.username ?? undefined,
|
||||
avatar: user.avatar ?? undefined,
|
||||
googleId: user.googleId ?? undefined,
|
||||
};
|
||||
};
|
||||
|
||||
const forwardGrpcCookies = (c: Context, cookies: readonly string[]) => {
|
||||
for (const cookie of cookies) {
|
||||
c.res.headers.append("set-cookie", cookie);
|
||||
}
|
||||
};
|
||||
|
||||
const authService = () => getAuthServiceClient();
|
||||
|
||||
const googleAuthReasonMap: Record<string, string> = {
|
||||
access_denied: "access_denied",
|
||||
missing_code: "missing_code",
|
||||
exchange_failed: "exchange_failed",
|
||||
userinfo_failed: "userinfo_failed",
|
||||
userinfo_parse_failed: "userinfo_parse_failed",
|
||||
missing_email: "missing_email",
|
||||
create_user_failed: "create_user_failed",
|
||||
update_user_failed: "update_user_failed",
|
||||
reload_user_failed: "reload_user_failed",
|
||||
session_failed: "session_failed",
|
||||
};
|
||||
|
||||
const normalizeGoogleAuthReason = (reason: unknown) => {
|
||||
const code = typeof reason === "string" ? reason.trim() : "";
|
||||
if (!code) {
|
||||
return "google_login_failed";
|
||||
}
|
||||
return googleAuthReasonMap[code] ?? "google_login_failed";
|
||||
};
|
||||
|
||||
export const clearSessionCookies = (c: Context) => {
|
||||
deleteCookie(c, "access_token", { path: "/" });
|
||||
deleteCookie(c, "refresh_token", { path: "/" });
|
||||
};
|
||||
|
||||
const frontendBaseUrl = () => (process.env.FRONTEND_BASE_URL || "").trim().replace(/\/$/, "");
|
||||
|
||||
const googleFinalizeUrl = (status: string, reason?: string) => {
|
||||
const base = frontendBaseUrl();
|
||||
if (!base) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const finalizePath = (process.env.GOOGLE_AUTH_FINALIZE_PATH || defaultGoogleFinalizePath).trim() || defaultGoogleFinalizePath;
|
||||
const path = finalizePath.startsWith("/") ? finalizePath : `/${finalizePath}`;
|
||||
const url = new URL(path, `${base}/`);
|
||||
url.searchParams.set("status", status);
|
||||
if (reason) {
|
||||
url.searchParams.set("reason", reason);
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const redirectToGoogleFinalize = (c: Context, status: string, reason?: string) => {
|
||||
const url = googleFinalizeUrl(status, reason);
|
||||
if (!url) {
|
||||
throw new HTTPException(500, { message: reason || "Google auth finalize URL is not configured" });
|
||||
}
|
||||
return c.redirect(url, 307);
|
||||
};
|
||||
|
||||
authRoute.get("/google/callback", async (c) => {
|
||||
const oauthError = c.req.query("error")?.trim();
|
||||
if (oauthError) {
|
||||
return redirectToGoogleFinalize(c, "error", oauthError);
|
||||
}
|
||||
|
||||
const code = c.req.query("code")?.trim();
|
||||
if (!code) {
|
||||
return redirectToGoogleFinalize(c, "error", "missing_code");
|
||||
}
|
||||
|
||||
try {
|
||||
const grpcCookies: string[] = [];
|
||||
await authService().completeGoogleLogin(
|
||||
{ code },
|
||||
getInternalGrpcMetadata(),
|
||||
{
|
||||
onMetadata: (metadata) => {
|
||||
for (const value of metadata.get("set-cookie")) {
|
||||
if (typeof value === "string" && value) {
|
||||
grpcCookies.push(value);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
forwardGrpcCookies(c, grpcCookies);
|
||||
return redirectToGoogleFinalize(c, "success");
|
||||
} catch (error) {
|
||||
const reason = normalizeGoogleAuthReason(error instanceof Error ? error.message : undefined);
|
||||
return redirectToGoogleFinalize(c, "error", reason);
|
||||
}
|
||||
});
|
||||
|
||||
export function registerAuthRoutes(app: Hono) {
|
||||
app.route("/auth", authRoute);
|
||||
app.get(
|
||||
"/logout",
|
||||
authenticate,
|
||||
async (c) => {
|
||||
const payload = c.get("jwtPayload") as any;
|
||||
const redis = c.get("redis");
|
||||
redis.del("refresh_uuid:" + payload["refresh_uuid"]);
|
||||
setCookie(c, "access_token", "", {
|
||||
expires: new Date(0),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
});
|
||||
setCookie(c, "refresh_token", "", {
|
||||
expires: new Date(0),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
});
|
||||
return c.json({ message: "Logged out successfully" });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import type { Hono } from 'hono';
|
||||
import { getManifest, saveImageFromStream, streamManifest } from '../modules/merge';
|
||||
|
||||
const guessContentType = (filename: string) => {
|
||||
const lower = filename.toLowerCase();
|
||||
if (lower.endsWith('.mp4')) return 'video/mp4';
|
||||
if (lower.endsWith('.webm')) return 'video/webm';
|
||||
if (lower.endsWith('.mov')) return 'video/quicktime';
|
||||
if (lower.endsWith('.mkv')) return 'video/x-matroska';
|
||||
if (lower.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
|
||||
return 'application/octet-stream';
|
||||
};
|
||||
|
||||
const buildStreamResponse = async (id: string) => {
|
||||
const manifest = await getManifest(id);
|
||||
if (!manifest) {
|
||||
return new Response(JSON.stringify({ error: 'Manifest not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
return new Response(streamManifest(manifest), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': guessContentType(manifest.filename),
|
||||
'Cache-Control': 'public, max-age=3600',
|
||||
'Content-Disposition': `inline; filename="${manifest.filename}"`,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export function registerDisplayRoutes(app: Hono) {
|
||||
app.get('/display/:id', async (c) => buildStreamResponse(c.req.param('id')));
|
||||
app.get('/play/index/:id', async (c) => buildStreamResponse(c.req.param('id')));
|
||||
|
||||
app.put('/display/:id/thumbnail', async (c) => {
|
||||
const arrayBuffer = await c.req.arrayBuffer();
|
||||
await saveImageFromStream(arrayBuffer, c.req.param('id'));
|
||||
return c.body('ok');
|
||||
});
|
||||
|
||||
app.put('/display/:id/metadata', async (c) => {
|
||||
return c.json({ status: 'not_implemented' }, 501);
|
||||
});
|
||||
|
||||
app.post('/display/:id/subs', async (c) => {
|
||||
return c.json({ status: 'not_implemented' }, 501);
|
||||
});
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { getManifest } from '@/server/modules/merge';
|
||||
import type { Hono } from 'hono';
|
||||
|
||||
export function registerManifestRoutes(app: Hono) {
|
||||
app.get('/manifest/:id', async (c) => {
|
||||
const manifest = await getManifest(c.req.param('id'));
|
||||
if (!manifest) {
|
||||
return c.json({ error: 'Manifest not found' }, 404);
|
||||
}
|
||||
return c.json(manifest);
|
||||
});
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { baseAPIURL } from '@/api/httpClientAdapter.server';
|
||||
import {
|
||||
createManifest,
|
||||
saveManifest,
|
||||
validateChunkUrls
|
||||
} from '@/server/modules/merge';
|
||||
import type { Hono, MiddlewareHandler } from 'hono';
|
||||
|
||||
const authMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
return fetch(`${baseAPIURL}/me`, {
|
||||
method: 'GET',
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
}).then(res => res.json()).then((r) => {
|
||||
if (r.data?.user) {
|
||||
return next();
|
||||
}
|
||||
else {
|
||||
throw new Error("Unauthorized");
|
||||
}
|
||||
}).catch(() => {
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
});
|
||||
};
|
||||
|
||||
export function registerMergeRoutes(app: Hono) {
|
||||
app.post('/merge', authMiddleware, async (c) => {
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const { filename, chunks, size } = body;
|
||||
|
||||
if (!filename || !Array.isArray(chunks) || chunks.length === 0) {
|
||||
return c.json({ error: 'invalid payload' }, 400);
|
||||
}
|
||||
|
||||
const hostError = validateChunkUrls(chunks);
|
||||
if (hostError) return c.json({ error: hostError }, 400);
|
||||
|
||||
const manifest = createManifest(filename, chunks, size);
|
||||
await saveManifest(manifest);
|
||||
|
||||
return c.json({
|
||||
status: 'ok',
|
||||
id: manifest.id,
|
||||
filename: manifest.filename,
|
||||
total_parts: manifest.total_parts,
|
||||
size: manifest.size,
|
||||
playback_url: `/display/${manifest.id}`,
|
||||
play_url: `/play/index/${manifest.id}`,
|
||||
manifest_url: `/manifest/${manifest.id}`,
|
||||
});
|
||||
} catch (e: any) {
|
||||
return c.json({ error: e?.message ?? String(e) }, 500);
|
||||
}
|
||||
});
|
||||
}
|
||||
99
src/server/routes/rpc/auth.ts
Normal file
99
src/server/routes/rpc/auth.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { validateFn } from "@hiogawa/tiny-rpc";
|
||||
import { getContext } from "hono/context-storage";
|
||||
import z from "zod";
|
||||
import { clearSessionCookies, ensureSessionUser } from "@/server/routes/auth";
|
||||
|
||||
const collectGrpcCookies = (metadata: import("@grpc/grpc-js").Metadata) => {
|
||||
const context = getContext();
|
||||
|
||||
for (const value of metadata.get("set-cookie")) {
|
||||
if (typeof value === "string" && value) {
|
||||
context.res.headers.append("set-cookie", value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const publicAuthMethods = {
|
||||
login: validateFn(
|
||||
z.object({
|
||||
email: z.string().email("Invalid email or password"),
|
||||
password: z.string().min(6, "Invalid email or password"),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
const response = await authClient.login(data, metadata, {
|
||||
onMetadata: collectGrpcCookies,
|
||||
});
|
||||
|
||||
return { user: ensureSessionUser(response.user) };
|
||||
}),
|
||||
register: validateFn(
|
||||
z.object({
|
||||
email: z.string().email("Invalid email"),
|
||||
username: z.string().min(3, "Username must be at least 3 characters"),
|
||||
password: z.string().min(6, "Password must be at least 6 characters"),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
const response = await authClient.register(data, metadata);
|
||||
|
||||
return { user: ensureSessionUser(response.user) };
|
||||
}),
|
||||
forgotPassword: validateFn(
|
||||
z.object({
|
||||
email: z.string().email("Invalid email"),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
const response = await authClient.forgotPassword(data, metadata);
|
||||
return { message: response.message || "If email exists, a reset link has been sent" };
|
||||
}),
|
||||
resetPassword: validateFn(
|
||||
z.object({
|
||||
token: z.string().trim().min(1),
|
||||
newPassword: z.string().min(6),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
const response = await authClient.resetPassword(data, metadata);
|
||||
return { message: response.message || "Password reset successfully" };
|
||||
}),
|
||||
getGoogleLoginUrl: async () => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
return await authClient.getGoogleLoginUrl({}, metadata);
|
||||
},
|
||||
};
|
||||
|
||||
export const protectedAuthMethods = {
|
||||
changePassword: validateFn(
|
||||
z.object({
|
||||
currentPassword: z.string().min(6),
|
||||
newPassword: z.string().min(6),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
const response = await authClient.changePassword(data, metadata);
|
||||
return { message: response.message || "Password changed successfully" };
|
||||
}),
|
||||
logout: async () => {
|
||||
const context = getContext();
|
||||
const authClient = context.get("authServiceClient");
|
||||
const metadata = context.get("internalGrpcMetadata");
|
||||
|
||||
await authClient.logout({}, metadata);
|
||||
clearSessionCookies(context);
|
||||
return { message: "Logged out" };
|
||||
},
|
||||
};
|
||||
@@ -1,39 +1,62 @@
|
||||
import { authenticate } from "@/server/middlewares/authenticate";
|
||||
import {
|
||||
exposeTinyRpc,
|
||||
httpServerAdapter,
|
||||
validateFn,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
import { exposeTinyRpc, httpServerAdapter } from "@hiogawa/tiny-rpc";
|
||||
import { Hono } from "hono";
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { jwt } from "hono/jwt";
|
||||
import { z } from "zod";
|
||||
import { Metadata } from "@grpc/grpc-js";
|
||||
import { meMethods } from "./me";
|
||||
import { protectedAuthMethods, publicAuthMethods } from "./auth";
|
||||
import { getGrpcMetadataFromContext } from "@/server/services/grpcClient";
|
||||
|
||||
const routes = {
|
||||
// define as a bare function
|
||||
checkId: (id: string) => {
|
||||
const context = getContext();
|
||||
console.log(context.req.raw.headers);
|
||||
return id === "good";
|
||||
},
|
||||
...meMethods
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
grpcMetadata: Metadata;
|
||||
}
|
||||
}
|
||||
|
||||
const protectedRoutes = {
|
||||
health: () => ({ ok: true }),
|
||||
...protectedAuthMethods,
|
||||
...meMethods,
|
||||
};
|
||||
export type RpcRoutes = typeof routes;
|
||||
|
||||
const publicRoutes = {
|
||||
...publicAuthMethods,
|
||||
};
|
||||
|
||||
export type RpcRoutes = typeof protectedRoutes & typeof publicRoutes;
|
||||
export const endpoint = "/rpc";
|
||||
export const pathsForGET: (keyof typeof routes)[] = ["checkId"];
|
||||
export const publicEndpoint = "/rpc-public";
|
||||
export const pathsForGET: (keyof typeof protectedRoutes)[] = ["health"];
|
||||
|
||||
export function registerRpcRoutes(app: Hono) {
|
||||
const protectedHandler = exposeTinyRpc({
|
||||
routes: protectedRoutes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const publicHandler = exposeTinyRpc({
|
||||
routes: publicRoutes,
|
||||
adapter: httpServerAdapter({ endpoint: publicEndpoint }),
|
||||
});
|
||||
|
||||
app.use(endpoint, authenticate, async (c, next) => {
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
const handler = exposeTinyRpc({
|
||||
routes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const res = await handler({ request: c.req.raw });
|
||||
|
||||
c.set("grpcMetadata", getGrpcMetadataFromContext());
|
||||
|
||||
const res = await protectedHandler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
return await next();
|
||||
});
|
||||
|
||||
app.use(publicEndpoint, async (c, next) => {
|
||||
if (c.req.path !== publicEndpoint && !c.req.path.startsWith(publicEndpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const res = await publicHandler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
|
||||
@@ -2,55 +2,677 @@ import { validateFn } from "@hiogawa/tiny-rpc";
|
||||
import { getContext } from "hono/context-storage";
|
||||
import z from "zod";
|
||||
|
||||
const optionalTrimmed = () => z.string().trim().min(1).optional();
|
||||
|
||||
export const meMethods = {
|
||||
getMe: async () => {
|
||||
const context = getContext();
|
||||
const userServiceClient = context.get("userServiceClient");
|
||||
const user = await userServiceClient.getUser({ id: context.get("userId") });
|
||||
const userPreferences = await userServiceClient.getPreferences({ userId: context.get("userId") });
|
||||
delete user.user?.password
|
||||
return {
|
||||
...user.user,
|
||||
...userPreferences.preferences
|
||||
};
|
||||
},
|
||||
updateMe: validateFn(z.object({
|
||||
username: z.string().min(3).optional(),
|
||||
avatar: z.url().optional(),
|
||||
role: z.string().optional(),
|
||||
planId: z.string().optional(),
|
||||
}))(
|
||||
async (data) => {
|
||||
const context = getContext();
|
||||
const user = await context.get("userServiceClient").updateUser({
|
||||
id: context.get("userId"),
|
||||
username: data.username,
|
||||
avatar: data.avatar,
|
||||
role: data.role,
|
||||
planId: data.planId,
|
||||
});
|
||||
delete user.user?.password
|
||||
return user.user;
|
||||
}
|
||||
),
|
||||
ChangePassword: validateFn(z.object({
|
||||
oldPassword: z.string().min(6),
|
||||
newPassword: z.string().min(6),
|
||||
}))(
|
||||
async (data) => {
|
||||
const context = getContext();
|
||||
const user = await context.get("userServiceClient").getUser({ id: context.get("userId") });
|
||||
if (!user.user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
const isMatch = Bun.password.verifySync(data.oldPassword, user.user!.password!, "bcrypt");
|
||||
if (!isMatch) {
|
||||
throw new Error("Invalid password");
|
||||
}
|
||||
await context.get("userServiceClient").updateUserPassword({
|
||||
id: context.get("userId"),
|
||||
newPassword: Bun.password.hashSync(data.newPassword, { algorithm: "bcrypt", cost: 12 }),
|
||||
});
|
||||
}
|
||||
)
|
||||
};
|
||||
getMe: async () => {
|
||||
const context = getContext();
|
||||
const accountClient = context.get("accountServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
const response = await accountClient.getMe({}, metadata);
|
||||
return response.user ?? null;
|
||||
},
|
||||
updateMe: validateFn(
|
||||
z.object({
|
||||
username: z.string().min(3).optional(),
|
||||
email: z.string().email().optional(),
|
||||
language: z.string().optional(),
|
||||
locale: z.string().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const accountClient = context.get("accountServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
const response = await accountClient.updateMe(data, metadata);
|
||||
return response.user ?? null;
|
||||
}),
|
||||
deleteMe: async () => {
|
||||
const context = getContext();
|
||||
const accountClient = context.get("accountServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await accountClient.deleteMe({}, metadata);
|
||||
},
|
||||
clearMyData: async () => {
|
||||
const context = getContext();
|
||||
const accountClient = context.get("accountServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await accountClient.clearMyData({}, metadata);
|
||||
},
|
||||
listVideos: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
search: optionalTrimmed(),
|
||||
status: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.listVideos(data, metadata);
|
||||
}),
|
||||
getVideo: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.getVideo(data, metadata);
|
||||
}),
|
||||
updateVideo: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
title: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
url: optionalTrimmed(),
|
||||
size: z.number().min(0).optional(),
|
||||
duration: z.number().min(0).optional(),
|
||||
format: optionalTrimmed(),
|
||||
status: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.updateVideo(data, metadata);
|
||||
}),
|
||||
deleteVideo: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.deleteVideo(data, metadata);
|
||||
}),
|
||||
listAdTemplates: async () => {
|
||||
const context = getContext();
|
||||
const adTemplatesClient = context.get("adTemplatesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adTemplatesClient.listAdTemplates({}, metadata);
|
||||
},
|
||||
createAdTemplate: validateFn(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
vastTagUrl: z.string().trim().url(),
|
||||
adFormat: optionalTrimmed(),
|
||||
duration: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adTemplatesClient = context.get("adTemplatesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adTemplatesClient.createAdTemplate(data, metadata);
|
||||
}),
|
||||
updateAdTemplate: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
vastTagUrl: z.string().trim().url(),
|
||||
adFormat: optionalTrimmed(),
|
||||
duration: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
isDefault: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adTemplatesClient = context.get("adTemplatesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adTemplatesClient.updateAdTemplate(data, metadata);
|
||||
}),
|
||||
deleteAdTemplate: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adTemplatesClient = context.get("adTemplatesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adTemplatesClient.deleteAdTemplate(data, metadata);
|
||||
}),
|
||||
getPreferences: async () => {
|
||||
const context = getContext();
|
||||
const preferencesClient = context.get("preferencesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await preferencesClient.getPreferences({}, metadata);
|
||||
},
|
||||
updatePreferences: validateFn(
|
||||
z.object({
|
||||
emailNotifications: z.boolean().optional(),
|
||||
pushNotifications: z.boolean().optional(),
|
||||
marketingNotifications: z.boolean().optional(),
|
||||
telegramNotifications: z.boolean().optional(),
|
||||
autoplay: z.boolean().optional(),
|
||||
loop: z.boolean().optional(),
|
||||
muted: z.boolean().optional(),
|
||||
showControls: z.boolean().optional(),
|
||||
pip: z.boolean().optional(),
|
||||
airplay: z.boolean().optional(),
|
||||
chromecast: z.boolean().optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const preferencesClient = context.get("preferencesServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await preferencesClient.updatePreferences(data, metadata);
|
||||
}),
|
||||
listNotifications: async () => {
|
||||
const context = getContext();
|
||||
const notificationsClient = context.get("notificationsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await notificationsClient.listNotifications({}, metadata);
|
||||
},
|
||||
markNotificationRead: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const notificationsClient = context.get("notificationsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await notificationsClient.markNotificationRead(data, metadata);
|
||||
}),
|
||||
markAllNotificationsRead: async () => {
|
||||
const context = getContext();
|
||||
const notificationsClient = context.get("notificationsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await notificationsClient.markAllNotificationsRead({}, metadata);
|
||||
},
|
||||
deleteNotification: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const notificationsClient = context.get("notificationsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await notificationsClient.deleteNotification(data, metadata);
|
||||
}),
|
||||
clearNotifications: async () => {
|
||||
const context = getContext();
|
||||
const notificationsClient = context.get("notificationsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await notificationsClient.clearNotifications({}, metadata);
|
||||
},
|
||||
getUploadUrl: validateFn(
|
||||
z.object({
|
||||
filename: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.getUploadUrl(data, metadata);
|
||||
}),
|
||||
createVideo: validateFn(
|
||||
z.object({
|
||||
title: z.string().trim().min(1),
|
||||
description: z.string().optional(),
|
||||
url: z.string().trim().min(1),
|
||||
size: z.number().min(0).optional(),
|
||||
duration: z.number().min(0).optional(),
|
||||
format: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const videosClient = context.get("videosServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await videosClient.createVideo(data, metadata);
|
||||
}),
|
||||
getUsage: async () => {
|
||||
const context = getContext();
|
||||
const usageClient = context.get("usageServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await usageClient.getUsage({}, metadata);
|
||||
},
|
||||
listDomains: async () => {
|
||||
const context = getContext();
|
||||
const domainsClient = context.get("domainsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await domainsClient.listDomains({}, metadata);
|
||||
},
|
||||
createDomain: validateFn(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const domainsClient = context.get("domainsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await domainsClient.createDomain(data, metadata);
|
||||
}),
|
||||
deleteDomain: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const domainsClient = context.get("domainsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await domainsClient.deleteDomain(data, metadata);
|
||||
}),
|
||||
listPlans: async () => {
|
||||
const context = getContext();
|
||||
const plansClient = context.get("plansServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await plansClient.listPlans({}, metadata);
|
||||
},
|
||||
listPaymentHistory: async () => {
|
||||
const context = getContext();
|
||||
const paymentsClient = context.get("paymentsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await paymentsClient.listPaymentHistory({}, metadata);
|
||||
},
|
||||
createPayment: validateFn(
|
||||
z.object({
|
||||
planId: z.string().trim().min(1),
|
||||
termMonths: z.number().int().min(1),
|
||||
paymentMethod: z.string().trim().min(1),
|
||||
topupAmount: z.number().min(0).optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const paymentsClient = context.get("paymentsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await paymentsClient.createPayment(data, metadata);
|
||||
}),
|
||||
topupWallet: validateFn(
|
||||
z.object({
|
||||
amount: z.number().min(0.01),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const paymentsClient = context.get("paymentsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await paymentsClient.topupWallet(data, metadata);
|
||||
}),
|
||||
downloadInvoice: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const paymentsClient = context.get("paymentsServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await paymentsClient.downloadInvoice(data, metadata);
|
||||
}),
|
||||
getAdminDashboard: async () => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
const response = await adminClient.getAdminDashboard({}, metadata);
|
||||
return response.dashboard ?? null;
|
||||
},
|
||||
listAdminUsers: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
search: optionalTrimmed(),
|
||||
role: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminUsers(data, metadata);
|
||||
}),
|
||||
createAdminUser: validateFn(
|
||||
z.object({
|
||||
email: z.string().trim().email(),
|
||||
username: optionalTrimmed(),
|
||||
password: z.string().min(6),
|
||||
role: z.string().trim().min(1),
|
||||
planId: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminUser(data, metadata);
|
||||
}),
|
||||
updateAdminUser: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
email: z.string().trim().email().optional(),
|
||||
username: optionalTrimmed(),
|
||||
password: z.string().min(6).optional(),
|
||||
role: z.string().trim().min(1).optional(),
|
||||
planId: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminUser(data, metadata);
|
||||
}),
|
||||
updateAdminUserRole: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
role: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminUserRole(data, metadata);
|
||||
}),
|
||||
deleteAdminUser: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminUser(data, metadata);
|
||||
}),
|
||||
listAdminVideos: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
search: optionalTrimmed(),
|
||||
userId: optionalTrimmed(),
|
||||
status: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminVideos(data, metadata);
|
||||
}),
|
||||
createAdminVideo: validateFn(
|
||||
z.object({
|
||||
userId: z.string().trim().min(1),
|
||||
title: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
url: z.string().trim().url(),
|
||||
size: z.number().min(0).optional(),
|
||||
duration: z.number().min(0).optional(),
|
||||
format: optionalTrimmed(),
|
||||
status: z.string().trim().min(1),
|
||||
adTemplateId: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminVideo(data, metadata);
|
||||
}),
|
||||
updateAdminVideo: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
userId: z.string().trim().min(1),
|
||||
title: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
url: z.string().trim().url(),
|
||||
size: z.number().min(0).optional(),
|
||||
duration: z.number().min(0).optional(),
|
||||
format: optionalTrimmed(),
|
||||
status: z.string().trim().min(1),
|
||||
adTemplateId: optionalTrimmed(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminVideo(data, metadata);
|
||||
}),
|
||||
deleteAdminVideo: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminVideo(data, metadata);
|
||||
}),
|
||||
listAdminPayments: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
userId: optionalTrimmed(),
|
||||
status: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminPayments(data, metadata);
|
||||
}),
|
||||
createAdminPayment: validateFn(
|
||||
z.object({
|
||||
userId: z.string().trim().min(1),
|
||||
planId: z.string().trim().min(1),
|
||||
termMonths: z.number().int().min(1),
|
||||
paymentMethod: z.string().trim().min(1),
|
||||
topupAmount: z.number().min(0).optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminPayment(data, metadata);
|
||||
}),
|
||||
updateAdminPayment: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
status: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminPayment(data, metadata);
|
||||
}),
|
||||
listAdminPlans: async () => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminPlans({}, metadata);
|
||||
},
|
||||
createAdminPlan: validateFn(
|
||||
z.object({
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
features: z.array(z.string().trim().min(1)).optional(),
|
||||
price: z.number().min(0),
|
||||
cycle: z.string().trim().min(1),
|
||||
storageLimit: z.number().int().min(1),
|
||||
uploadLimit: z.number().int().min(1),
|
||||
isActive: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminPlan(data, metadata);
|
||||
}),
|
||||
updateAdminPlan: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
features: z.array(z.string().trim().min(1)).optional(),
|
||||
price: z.number().min(0),
|
||||
cycle: z.string().trim().min(1),
|
||||
storageLimit: z.number().int().min(1),
|
||||
uploadLimit: z.number().int().min(1),
|
||||
isActive: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminPlan(data, metadata);
|
||||
}),
|
||||
deleteAdminPlan: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminPlan(data, metadata);
|
||||
}),
|
||||
listAdminAdTemplates: validateFn(
|
||||
z.object({
|
||||
page: z.number().int().min(1).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
userId: optionalTrimmed(),
|
||||
search: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminAdTemplates(data, metadata);
|
||||
}),
|
||||
createAdminAdTemplate: validateFn(
|
||||
z.object({
|
||||
userId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
vastTagUrl: z.string().trim().url(),
|
||||
adFormat: z.string().trim().min(1).optional(),
|
||||
duration: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminAdTemplate(data, metadata);
|
||||
}),
|
||||
updateAdminAdTemplate: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
userId: z.string().trim().min(1),
|
||||
name: z.string().trim().min(1),
|
||||
description: optionalTrimmed(),
|
||||
vastTagUrl: z.string().trim().url(),
|
||||
adFormat: z.string().trim().min(1).optional(),
|
||||
duration: z.number().int().min(0).optional(),
|
||||
isActive: z.boolean(),
|
||||
isDefault: z.boolean(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminAdTemplate(data, metadata);
|
||||
}),
|
||||
deleteAdminAdTemplate: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.deleteAdminAdTemplate(data, metadata);
|
||||
}),
|
||||
listAdminJobs: validateFn(
|
||||
z.object({
|
||||
offset: z.number().int().min(0).optional(),
|
||||
limit: z.number().int().min(1).max(100).optional(),
|
||||
agentId: optionalTrimmed(),
|
||||
}).optional().default({}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminJobs(data, metadata);
|
||||
}),
|
||||
getAdminJob: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.getAdminJob(data, metadata);
|
||||
}),
|
||||
getAdminJobLogs: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.getAdminJobLogs(data, metadata);
|
||||
}),
|
||||
createAdminJob: validateFn(
|
||||
z.object({
|
||||
command: z.string().trim().min(1),
|
||||
image: optionalTrimmed(),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
priority: z.number().int().optional(),
|
||||
userId: optionalTrimmed(),
|
||||
name: optionalTrimmed(),
|
||||
timeLimit: z.number().int().min(0).optional(),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.createAdminJob(data, metadata);
|
||||
}),
|
||||
cancelAdminJob: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.cancelAdminJob(data, metadata);
|
||||
}),
|
||||
retryAdminJob: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.retryAdminJob(data, metadata);
|
||||
}),
|
||||
listAdminAgents: async () => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.listAdminAgents({}, metadata);
|
||||
},
|
||||
restartAdminAgent: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.restartAdminAgent(data, metadata);
|
||||
}),
|
||||
updateAdminAgent: validateFn(
|
||||
z.object({
|
||||
id: z.string().trim().min(1),
|
||||
}),
|
||||
)(async (data) => {
|
||||
const context = getContext();
|
||||
const adminClient = context.get("adminServiceClient");
|
||||
const metadata = context.get("grpcMetadata");
|
||||
return await adminClient.updateAdminAgent(data, metadata);
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -1,16 +1,63 @@
|
||||
import { ChannelCredentials, credentials } from "@grpc/grpc-js";
|
||||
import { ChannelCredentials, Metadata, credentials } from "@grpc/grpc-js";
|
||||
import type { Hono } from "hono";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
import { Hono } from "node_modules/hono/dist/types/hono";
|
||||
import { PromisifiedClient, promisifyClient } from "../utils/grpcHelper";
|
||||
import { UserServiceClient } from "../utils/proto/v1/user";
|
||||
import {
|
||||
AccountServiceClient,
|
||||
NotificationsServiceClient,
|
||||
PreferencesServiceClient,
|
||||
UsageServiceClient,
|
||||
type AccountServiceClient as AccountServiceClientType,
|
||||
type NotificationsServiceClient as NotificationsServiceClientType,
|
||||
type PreferencesServiceClient as PreferencesServiceClientType,
|
||||
type UsageServiceClient as UsageServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/account";
|
||||
import {
|
||||
AdminServiceClient,
|
||||
type AdminServiceClient as AdminServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/admin";
|
||||
import {
|
||||
AdTemplatesServiceClient,
|
||||
DomainsServiceClient,
|
||||
PlansServiceClient,
|
||||
type AdTemplatesServiceClient as AdTemplatesServiceClientType,
|
||||
type DomainsServiceClient as DomainsServiceClientType,
|
||||
type PlansServiceClient as PlansServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/catalog";
|
||||
import {
|
||||
AuthServiceClient,
|
||||
type AuthServiceClient as AuthServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/auth";
|
||||
import {
|
||||
PaymentsServiceClient,
|
||||
type PaymentsServiceClient as PaymentsServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/payments";
|
||||
import {
|
||||
VideosServiceClient,
|
||||
type VideosServiceClient as VideosServiceClientType,
|
||||
} from "@/server/gen/proto/app/v1/videos";
|
||||
import { promisifyClient, PromisifiedClient } from "../utils/grpcHelper";
|
||||
|
||||
declare module "hono" {
|
||||
interface ContextVariableMap {
|
||||
userServiceClient: PromisifiedClient<UserServiceClient>;
|
||||
accountServiceClient: PromisifiedClient<AccountServiceClientType>;
|
||||
authServiceClient: PromisifiedClient<AuthServiceClientType>;
|
||||
adminServiceClient: PromisifiedClient<AdminServiceClientType>;
|
||||
adTemplatesServiceClient: PromisifiedClient<AdTemplatesServiceClientType>;
|
||||
videosServiceClient: PromisifiedClient<VideosServiceClientType>;
|
||||
domainsServiceClient: PromisifiedClient<DomainsServiceClientType>;
|
||||
plansServiceClient: PromisifiedClient<PlansServiceClientType>;
|
||||
paymentsServiceClient: PromisifiedClient<PaymentsServiceClientType>;
|
||||
preferencesServiceClient: PromisifiedClient<PreferencesServiceClientType>;
|
||||
notificationsServiceClient: PromisifiedClient<NotificationsServiceClientType>;
|
||||
usageServiceClient: PromisifiedClient<UsageServiceClientType>;
|
||||
internalGrpcMetadata: Metadata;
|
||||
}
|
||||
}
|
||||
const DEFAULT_GRPC_ADDRESS = '127.0.0.1:9000';
|
||||
|
||||
const DEFAULT_GRPC_ADDRESS = "127.0.0.1:9000";
|
||||
|
||||
const grpcAddress = () => process.env.STREAM_API_GRPC_ADDR || DEFAULT_GRPC_ADDRESS;
|
||||
|
||||
let sharedCredentials: ChannelCredentials | undefined;
|
||||
const getCredentials = () => {
|
||||
if (!sharedCredentials) {
|
||||
@@ -18,43 +65,178 @@ const getCredentials = () => {
|
||||
}
|
||||
return sharedCredentials;
|
||||
};
|
||||
export const getUserServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (context) {
|
||||
return context.get("userServiceClient");
|
||||
|
||||
const buildForwardMetadataFromHeaders = (headers: Headers): Metadata => {
|
||||
const metadata = new Metadata();
|
||||
|
||||
for (const name of ["user-agent", "x-forwarded-for", "x-real-ip", "x-request-id"]) {
|
||||
const value = headers.get(name);
|
||||
if (value) {
|
||||
metadata.set(name, value);
|
||||
}
|
||||
throw new Error("No context available to get UserServiceClient");
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
// (method) UserServiceClient.getUserByEmail(request: GetUserByEmailRequest, callback: (error: ServiceError | null, response: GetUserResponse) => void): ClientUnaryCall (+2 overloads)
|
||||
|
||||
// const unaryCall = <TResponse>(
|
||||
// executor: (
|
||||
// metadata: Metadata,
|
||||
// options: Partial<CallOptions>,
|
||||
// callback: (error: ServiceError | null, response: TResponse) => void,
|
||||
// ) => { metadata?: Metadata; trailer?: Metadata },
|
||||
// ): Promise<TResponse> => {
|
||||
// // const { metadata } = createMetadataFromContext();
|
||||
const buildInternalMetadata = () => {
|
||||
const context = tryGetContext();
|
||||
const metadata = context ? buildForwardMetadataFromHeaders(context.req.raw.headers) : new Metadata();
|
||||
const marker = process.env.STREAM_INTERNAL_AUTH_MARKER;
|
||||
|
||||
// return new Promise<TResponse>((resolve, reject) => {
|
||||
// executor({
|
||||
// deadline: Date.now() + 10_000,
|
||||
// }, (error, response) => {
|
||||
// if (error) {
|
||||
// reject(normalizeGrpcError(error));
|
||||
// return;
|
||||
// }
|
||||
if (!marker) {
|
||||
throw new Error("STREAM_INTERNAL_AUTH_MARKER is not configured");
|
||||
}
|
||||
|
||||
// // appendSetCookiesToResponse(call.metadata?.get('set-cookie') ?? []);
|
||||
// resolve(response);
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
metadata.set("x-stream-internal-auth", marker);
|
||||
return metadata;
|
||||
};
|
||||
|
||||
const buildActorMetadata = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to build actor metadata");
|
||||
}
|
||||
|
||||
const metadata = buildInternalMetadata();
|
||||
const userId = context.get("userId");
|
||||
const role = context.get("role");
|
||||
const email = context.get("email");
|
||||
|
||||
if (!userId || !role) {
|
||||
throw new Error("Authenticated actor context is missing");
|
||||
}
|
||||
|
||||
metadata.set("x-stream-actor-id", userId);
|
||||
metadata.set("x-stream-actor-role", role);
|
||||
if (email) {
|
||||
metadata.set("x-stream-actor-email", email);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
};
|
||||
|
||||
export const getAccountServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get AccountServiceClient");
|
||||
}
|
||||
return context.get("accountServiceClient");
|
||||
};
|
||||
|
||||
export const getAuthServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get AuthServiceClient");
|
||||
}
|
||||
return context.get("authServiceClient");
|
||||
};
|
||||
|
||||
export const getAdminServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get AdminServiceClient");
|
||||
}
|
||||
return context.get("adminServiceClient");
|
||||
};
|
||||
|
||||
export const getAdTemplatesServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get AdTemplatesServiceClient");
|
||||
}
|
||||
return context.get("adTemplatesServiceClient");
|
||||
};
|
||||
|
||||
export const getVideosServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get VideosServiceClient");
|
||||
}
|
||||
return context.get("videosServiceClient");
|
||||
};
|
||||
|
||||
export const getDomainsServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get DomainsServiceClient");
|
||||
}
|
||||
return context.get("domainsServiceClient");
|
||||
};
|
||||
|
||||
export const getPlansServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get PlansServiceClient");
|
||||
}
|
||||
return context.get("plansServiceClient");
|
||||
};
|
||||
|
||||
export const getPaymentsServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get PaymentsServiceClient");
|
||||
}
|
||||
return context.get("paymentsServiceClient");
|
||||
};
|
||||
|
||||
export const getPreferencesServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get PreferencesServiceClient");
|
||||
}
|
||||
return context.get("preferencesServiceClient");
|
||||
};
|
||||
|
||||
export const getNotificationsServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get NotificationsServiceClient");
|
||||
}
|
||||
return context.get("notificationsServiceClient");
|
||||
};
|
||||
|
||||
export const getUsageServiceClient = () => {
|
||||
const context = tryGetContext();
|
||||
if (!context) {
|
||||
throw new Error("No context available to get UsageServiceClient");
|
||||
}
|
||||
return context.get("usageServiceClient");
|
||||
};
|
||||
|
||||
export const getGrpcMetadataFromContext = () => buildActorMetadata();
|
||||
|
||||
export const getInternalGrpcMetadata = () => buildInternalMetadata();
|
||||
|
||||
export const setupServices = (app: Hono) => {
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("userServiceClient", promisifyClient(new UserServiceClient(grpcAddress(), getCredentials())));
|
||||
return await next();
|
||||
});
|
||||
}
|
||||
app.use("*", async (c, next) => {
|
||||
const creds = getCredentials();
|
||||
|
||||
const accountClient = new AccountServiceClient(grpcAddress(), creds);
|
||||
const authClient = new AuthServiceClient(grpcAddress(), creds);
|
||||
const adminClient = new AdminServiceClient(grpcAddress(), creds);
|
||||
const adTemplatesClient = new AdTemplatesServiceClient(grpcAddress(), creds);
|
||||
const videosClient = new VideosServiceClient(grpcAddress(), creds);
|
||||
const domainsClient = new DomainsServiceClient(grpcAddress(), creds);
|
||||
const plansClient = new PlansServiceClient(grpcAddress(), creds);
|
||||
const paymentsClient = new PaymentsServiceClient(grpcAddress(), creds);
|
||||
const preferencesClient = new PreferencesServiceClient(grpcAddress(), creds);
|
||||
const notificationsClient = new NotificationsServiceClient(grpcAddress(), creds);
|
||||
const usageClient = new UsageServiceClient(grpcAddress(), creds);
|
||||
|
||||
c.set("accountServiceClient", promisifyClient(accountClient));
|
||||
c.set("authServiceClient", promisifyClient(authClient));
|
||||
c.set("adminServiceClient", promisifyClient(adminClient));
|
||||
c.set("adTemplatesServiceClient", promisifyClient(adTemplatesClient));
|
||||
c.set("videosServiceClient", promisifyClient(videosClient));
|
||||
c.set("domainsServiceClient", promisifyClient(domainsClient));
|
||||
c.set("plansServiceClient", promisifyClient(plansClient));
|
||||
c.set("paymentsServiceClient", promisifyClient(paymentsClient));
|
||||
c.set("preferencesServiceClient", promisifyClient(preferencesClient));
|
||||
c.set("notificationsServiceClient", promisifyClient(notificationsClient));
|
||||
c.set("usageServiceClient", promisifyClient(usageClient));
|
||||
c.set("internalGrpcMetadata", getInternalGrpcMetadata());
|
||||
|
||||
await next();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { ClientUnaryCall, ServiceError, status } from "@grpc/grpc-js";
|
||||
import { ClientUnaryCall, Metadata, ServiceError, StatusObject, status } from "@grpc/grpc-js";
|
||||
|
||||
// 1. Định nghĩa lại UnaryCallback để bắt được kiểu TRes chính xác hơn
|
||||
type UnaryCallback<TRes> = (error: ServiceError | null, response: TRes) => void;
|
||||
|
||||
// 2. Ép TypeScript tìm đúng Overload có Callback
|
||||
// Chúng ta sử dụng tham số thứ 2 của hàm (index 1) để lấy TRes
|
||||
type ResponseOf<T> = T extends {
|
||||
(req: any, callback: UnaryCallback<infer TRes>): ClientUnaryCall;
|
||||
(req: any, metadata: any, callback: UnaryCallback<infer TRes>): ClientUnaryCall;
|
||||
@@ -16,63 +13,70 @@ type RequestOf<T> = T extends {
|
||||
(req: infer TReq, metadata: any, callback: UnaryCallback<any>): ClientUnaryCall;
|
||||
} ? TReq : any;
|
||||
|
||||
// 3. Filter để chỉ lấy các Method thực sự là Unary
|
||||
type UnaryKeys<T> = {
|
||||
[K in keyof T]: T[K] extends (...args: any[]) => ClientUnaryCall ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type GrpcCallHooks = {
|
||||
onMetadata?: (metadata: Metadata) => void;
|
||||
onStatus?: (status: StatusObject) => void;
|
||||
};
|
||||
|
||||
export type PromisifiedClient<TClient> = {
|
||||
[K in UnaryKeys<TClient>]: (
|
||||
req: RequestOf<TClient[K]>
|
||||
req: RequestOf<TClient[K]>,
|
||||
metadata?: Metadata,
|
||||
hooks?: GrpcCallHooks,
|
||||
) => Promise<ResponseOf<TClient[K]>>;
|
||||
};
|
||||
|
||||
// ... Các hàm normalizeGrpcError giữ nguyên ...
|
||||
|
||||
export function promisifyClient<TClient extends object>(
|
||||
client: TClient
|
||||
client: TClient,
|
||||
): PromisifiedClient<TClient> {
|
||||
const result = {} as any;
|
||||
|
||||
// Thay vì quét Prototype, ta quét các key thực tế hiện có trên instance của client
|
||||
// gRPC dynamic clients thường định nghĩa method trực tiếp hoặc qua proxy
|
||||
const allKeys = new Set([
|
||||
...Object.getOwnPropertyNames(client),
|
||||
...Object.getOwnPropertyNames(Object.getPrototypeOf(client))
|
||||
...Object.getOwnPropertyNames(Object.getPrototypeOf(client)),
|
||||
]);
|
||||
|
||||
allKeys.forEach((key) => {
|
||||
if (key === "constructor") return;
|
||||
|
||||
const originalMethod = (client as any)[key];
|
||||
|
||||
// Chỉ xử lý nếu nó là function và không phải là các hàm tiện ích của gRPC (bắt đầu bằng $)
|
||||
if (typeof originalMethod === "function" && !key.startsWith('$')) {
|
||||
|
||||
result[key] = (req: any) =>
|
||||
new Promise((resolve, reject) => {
|
||||
// QUAN TRỌNG: Sử dụng .bind(client) hoặc .call(client, ...)
|
||||
// để tránh lỗi "No implementation found" do mất context 'this'
|
||||
originalMethod.call(
|
||||
client,
|
||||
req,
|
||||
(error: ServiceError | null, response: any) => {
|
||||
if (error) {
|
||||
reject(normalizeGrpcError(error));
|
||||
return;
|
||||
}
|
||||
resolve(response);
|
||||
if (typeof originalMethod === "function" && !key.startsWith("$")) {
|
||||
result[key] = (
|
||||
req: any,
|
||||
metadata?: Metadata,
|
||||
hooks?: GrpcCallHooks,
|
||||
) => new Promise((resolve, reject) => {
|
||||
const call: ClientUnaryCall = originalMethod.call(
|
||||
client,
|
||||
req,
|
||||
metadata ?? new Metadata(),
|
||||
(error: ServiceError | null, response: any) => {
|
||||
if (error) {
|
||||
reject(normalizeGrpcError(error));
|
||||
return;
|
||||
}
|
||||
);
|
||||
});
|
||||
resolve(response);
|
||||
},
|
||||
);
|
||||
|
||||
if (hooks?.onMetadata) {
|
||||
call.on("metadata", hooks.onMetadata);
|
||||
}
|
||||
if (hooks?.onStatus) {
|
||||
call.on("status", hooks.onStatus);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
function grpcCodeToHttpStatus (code?: number) {
|
||||
function grpcCodeToHttpStatus(code?: number) {
|
||||
switch (code) {
|
||||
case status.INVALID_ARGUMENT:
|
||||
return 400;
|
||||
@@ -85,30 +89,36 @@ function grpcCodeToHttpStatus (code?: number) {
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGrpcError(error: ServiceError) {
|
||||
const normalized = new Error(error.details || error.message) as Error & {
|
||||
status?: number;
|
||||
code?: number;
|
||||
body?: { code?: number; message?: string; data?: unknown };
|
||||
};
|
||||
|
||||
normalized.code = error.code;
|
||||
normalized.status = grpcCodeToHttpStatus(error.code);
|
||||
|
||||
const trailerBody = error.metadata?.get('x-error-body')?.[0];
|
||||
if (typeof trailerBody === 'string' && trailerBody) {
|
||||
const trailerBody = error.metadata?.get("x-error-body")?.[0];
|
||||
if (typeof trailerBody === "string" && trailerBody) {
|
||||
try {
|
||||
normalized.body = JSON.parse(trailerBody) as { code?: number; message?: string; data?: unknown };
|
||||
normalized.body = JSON.parse(trailerBody) as {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
if (normalized.body?.message) {
|
||||
normalized.message = normalized.body.message;
|
||||
}
|
||||
if (typeof normalized.body?.code === 'number') {
|
||||
if (typeof normalized.body?.code === "number") {
|
||||
normalized.status = normalized.body.code;
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed structured error payloads
|
||||
// ignore malformed payload
|
||||
}
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,39 +1,49 @@
|
||||
import { RedisClient } from "bun";
|
||||
import { Context } from "hono";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
import {
|
||||
setCookie
|
||||
} from 'hono/cookie';
|
||||
import { User } from "./proto/v1/user";
|
||||
export const redisClient = (): RedisClient => {
|
||||
const context = tryGetContext<any>();
|
||||
const redis = context?.get("redis") as RedisClient | undefined;
|
||||
if (!redis) {
|
||||
throw new Error("Redis client not found in context");
|
||||
}
|
||||
return redis;
|
||||
import { setCookie } from "hono/cookie";
|
||||
import type { User } from "@/server/gen/proto/app/v1/common";
|
||||
|
||||
export const redisClient = () => {
|
||||
const context = tryGetContext<any>();
|
||||
const redis = context?.get("redis")
|
||||
if (!redis) {
|
||||
throw new Error("Redis client not found in context");
|
||||
}
|
||||
return redis;
|
||||
};
|
||||
|
||||
export async function generateAndSetTokens(c: Context, userData: User) {
|
||||
const redis = c.get("redis");
|
||||
const jwtProvider = c.get("jwtProvider");
|
||||
return await jwtProvider.generateTokenPair(userData.id!, userData.email!, userData.role!).then((td) => {
|
||||
redis.set("refresh_uuid:" + td.refreshUUID, userData.id!, "EX", td.rtExpires - Math.floor(Date.now() / 1000));
|
||||
setCookie(c, "access_token", td.accessToken, {
|
||||
expires: new Date(td.atExpires * 1000),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
path: "/",
|
||||
});
|
||||
setCookie(c, "refresh_token", td.refreshToken, {
|
||||
expires: new Date(td.rtExpires * 1000),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
path: "/",
|
||||
});
|
||||
return td;
|
||||
}).catch((e) => {
|
||||
console.error("Error generating tokens", e);
|
||||
throw e;
|
||||
const redis = c.get("redis");
|
||||
const jwtProvider = c.get("jwtProvider");
|
||||
|
||||
return await jwtProvider
|
||||
.generateTokenPair(userData.id!, userData.email!, userData.role!)
|
||||
.then((td) => {
|
||||
redis.set(
|
||||
"refresh_uuid:" + td.refreshUUID,
|
||||
userData.id!,
|
||||
"EX",
|
||||
td.rtExpires - Math.floor(Date.now() / 1000),
|
||||
);
|
||||
|
||||
setCookie(c, "access_token", td.accessToken, {
|
||||
expires: new Date(td.atExpires * 1000),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
setCookie(c, "refresh_token", td.refreshToken, {
|
||||
expires: new Date(td.rtExpires * 1000),
|
||||
httpOnly: true,
|
||||
secure: false,
|
||||
path: "/",
|
||||
});
|
||||
|
||||
return td;
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
console.error("Error generating tokens", e);
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,219 +1,236 @@
|
||||
// import { client, type AuthUserPayload, type ResponseResponse } from '@/api/client';
|
||||
import { client } from '@/api/rpcclient';
|
||||
import { TinyMqttClient } from '@/lib/liteMqtt';
|
||||
import type { User } from '@/server/utils/proto/v1/user';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { TinyMqttClient } from "@/lib/liteMqtt";
|
||||
import type { User } from "@/server/gen/proto/app/v1/common";
|
||||
import { useTranslation } from "i18next-vue";
|
||||
import { defineStore } from "pinia";
|
||||
import { ref, watch } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
type ProfileUpdatePayload = {
|
||||
username?: string;
|
||||
language?: string;
|
||||
locale?: string;
|
||||
username?: string;
|
||||
email?: string;
|
||||
language?: string;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt';
|
||||
|
||||
const getGoogleLoginPath = () => {
|
||||
const basePath = client.baseUrl.startsWith('/') ? client.baseUrl : `/${client.baseUrl}`;
|
||||
return `${basePath}/auth/google/login`;
|
||||
type AuthUserPayload = User & {
|
||||
plan_id?: string;
|
||||
plan_expires_at?: string;
|
||||
plan_expiring_soon?: boolean;
|
||||
wallet_balance?: number;
|
||||
};
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<AuthUserPayload | null>(null);
|
||||
const router = useRouter();
|
||||
const { t, i18next } = useTranslation();
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const initialized = ref(false);
|
||||
const mqttBrokerUrl = "wss://mqtt-dashboard.com:8884/mqtt";
|
||||
|
||||
let mqttClient: TinyMqttClient | undefined;
|
||||
const normalizeUser = (user: User | null): AuthUserPayload | null => {
|
||||
if (!user) return null;
|
||||
|
||||
const clearMqttClient = () => {
|
||||
mqttClient?.disconnect();
|
||||
mqttClient = undefined;
|
||||
};
|
||||
return {
|
||||
...user,
|
||||
plan_id: user.planId,
|
||||
plan_expires_at: user.planExpiresAt,
|
||||
plan_expiring_soon: user.planExpiringSoon,
|
||||
wallet_balance: user.walletBalance,
|
||||
};
|
||||
};
|
||||
|
||||
const clearState = () => {
|
||||
user.value = null;
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
initialized.value = false;
|
||||
};
|
||||
export const useAuthStore = defineStore("auth", () => {
|
||||
const user = ref<AuthUserPayload | null>(null);
|
||||
const router = useRouter();
|
||||
const { t, i18next } = useTranslation();
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const initialized = ref(false);
|
||||
|
||||
watch(() => user.value?.id, (userId) => {
|
||||
if (import.meta.env.SSR) return;
|
||||
let mqttClient: TinyMqttClient | undefined;
|
||||
|
||||
clearMqttClient();
|
||||
if (!userId) return;
|
||||
mqttClient = new TinyMqttClient(
|
||||
mqttBrokerUrl,
|
||||
[['ecos1231231', userId, '#'].join('/')],
|
||||
(topic, message) => {
|
||||
console.log(`Tín hiệu nhận được [${topic}]:`, message);
|
||||
}
|
||||
);
|
||||
mqttClient.connect();
|
||||
});
|
||||
watch(() => user.value?.language, (lng) => i18next.changeLanguage(lng))
|
||||
async function fetchMe() {
|
||||
const response = await client.getMe();
|
||||
user.value = await client.getMe();
|
||||
i18next.changeLanguage(response?.language || 'en');
|
||||
return response;
|
||||
}
|
||||
const clearMqttClient = () => {
|
||||
mqttClient?.disconnect();
|
||||
mqttClient = undefined;
|
||||
};
|
||||
|
||||
async function init() {
|
||||
if (initialized.value) return;
|
||||
const clearState = () => {
|
||||
user.value = null;
|
||||
loading.value = false;
|
||||
error.value = null;
|
||||
initialized.value = false;
|
||||
};
|
||||
|
||||
try {
|
||||
await fetchMe();
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
initialized.value = true;
|
||||
}
|
||||
}
|
||||
watch(
|
||||
() => user.value?.id,
|
||||
(userId) => {
|
||||
if (import.meta.env.SSR) return;
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
clearMqttClient();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const response = await client.auth.loginCreate({
|
||||
email,
|
||||
password,
|
||||
});
|
||||
const nextUser = extractUser(response.data as AuthResponseBody);
|
||||
|
||||
if (!nextUser) {
|
||||
throw new Error(t('auth.errors.loginNoUserData'));
|
||||
}
|
||||
|
||||
user.value = nextUser;
|
||||
await router.push('/');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
error.value = t('auth.errors.loginFailed', { error: e.message || t('auth.errors.unknown') });
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function loginWithGoogle() {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.location.assign(getGoogleLoginPath());
|
||||
}
|
||||
|
||||
async function register(username: string, email: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await client.auth.registerCreate({
|
||||
username,
|
||||
email,
|
||||
password,
|
||||
});
|
||||
await router.push('/login');
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
error.value = t('auth.errors.registrationFailed', { error: e.message || t('auth.errors.unknown') });
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(data: ProfileUpdatePayload) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await client.me.putMe(data, { baseUrl: '/r' });
|
||||
const nextUser = extractUser(response.data as AuthResponseBody);
|
||||
|
||||
if (nextUser) {
|
||||
user.value = { ...(user.value ?? {}), ...nextUser } as AuthUserPayload;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('Update profile error', e);
|
||||
error.value = t('auth.errors.updateProfileFailed', { error: e.message || t('auth.errors.unknown') });
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setLanguage(locale: string) {
|
||||
if (!user.value?.id) {
|
||||
return { ok: true as const, fallbackOnly: true as const };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfile({ language: locale, locale });
|
||||
return { ok: true as const, fallbackOnly: false as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, fallbackOnly: true as const, error: e };
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword: string, newPassword: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await client.auth.changePasswordCreate({
|
||||
current_password: currentPassword,
|
||||
new_password: newPassword,
|
||||
}, { baseUrl: '/r' });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
console.error('Change password error', e);
|
||||
error.value = t('auth.errors.changePasswordFailed', { error: e.message || t('auth.errors.unknown') });
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await client.auth.logoutCreate();
|
||||
} catch (e) {
|
||||
console.error('Logout error', e);
|
||||
} finally {
|
||||
clearMqttClient();
|
||||
user.value = null;
|
||||
loading.value = false;
|
||||
await router.push('/login');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
init,
|
||||
fetchMe,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
register,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
setLanguage,
|
||||
logout,
|
||||
$reset: () => {
|
||||
clearMqttClient();
|
||||
clearState();
|
||||
mqttClient = new TinyMqttClient(
|
||||
mqttBrokerUrl,
|
||||
[["ecos1231231", userId, "#"].join("/")],
|
||||
(topic, message) => {
|
||||
console.log(`Tín hiệu nhận được [${topic}]:`, message);
|
||||
},
|
||||
};
|
||||
);
|
||||
mqttClient.connect();
|
||||
},
|
||||
);
|
||||
|
||||
watch(() => user.value?.language, (lng) => i18next.changeLanguage(lng));
|
||||
|
||||
async function fetchMe() {
|
||||
const response = await rpcClient.getMe();
|
||||
const normalized = normalizeUser(response as User | null);
|
||||
user.value = normalized;
|
||||
i18next.changeLanguage(normalized?.language || "en");
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
if (initialized.value) return;
|
||||
|
||||
try {
|
||||
await fetchMe();
|
||||
} catch {
|
||||
user.value = null;
|
||||
} finally {
|
||||
initialized.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await rpcClient.login({ email, password });
|
||||
const nextUser = normalizeUser(response.user ?? null);
|
||||
|
||||
if (!nextUser) {
|
||||
throw new Error(t("auth.errors.loginNoUserData"));
|
||||
}
|
||||
|
||||
user.value = nextUser;
|
||||
await router.push("/");
|
||||
} catch (e: any) {
|
||||
error.value = t("auth.errors.loginFailed", {
|
||||
error: e.message || t("auth.errors.unknown"),
|
||||
});
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loginWithGoogle() {
|
||||
if (typeof window === "undefined") return;
|
||||
const response = await rpcClient.getGoogleLoginUrl();
|
||||
if (!response.url) {
|
||||
throw new Error(t("auth.errors.unknown"));
|
||||
}
|
||||
window.location.assign(response.url);
|
||||
}
|
||||
|
||||
async function register(username: string, email: string, password: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await rpcClient.register({ username, email, password });
|
||||
await router.push("/login");
|
||||
} catch (e: any) {
|
||||
error.value = t("auth.errors.registrationFailed", {
|
||||
error: e.message || t("auth.errors.unknown"),
|
||||
});
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateProfile(data: ProfileUpdatePayload) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const response = await rpcClient.updateMe(data);
|
||||
const nextUser = normalizeUser(response as User | null);
|
||||
if (nextUser) {
|
||||
user.value = { ...(user.value ?? {}), ...nextUser };
|
||||
}
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
error.value = t("auth.errors.updateProfileFailed", {
|
||||
error: e.message || t("auth.errors.unknown"),
|
||||
});
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setLanguage(locale: string) {
|
||||
if (!user.value?.id) {
|
||||
return { ok: true as const, fallbackOnly: true as const };
|
||||
}
|
||||
|
||||
try {
|
||||
await updateProfile({ language: locale, locale });
|
||||
return { ok: true as const, fallbackOnly: false as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, fallbackOnly: true as const, error: e };
|
||||
}
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword: string, newPassword: string) {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await rpcClient.changePassword({ currentPassword, newPassword });
|
||||
return true;
|
||||
} catch (e: any) {
|
||||
error.value = t("auth.errors.changePasswordFailed", {
|
||||
error: e.message || t("auth.errors.unknown"),
|
||||
});
|
||||
throw e;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
await rpcClient.logout();
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
clearMqttClient();
|
||||
user.value = null;
|
||||
loading.value = false;
|
||||
await router.push("/login");
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loading,
|
||||
error,
|
||||
initialized,
|
||||
init,
|
||||
fetchMe,
|
||||
login,
|
||||
loginWithGoogle,
|
||||
register,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
setLanguage,
|
||||
logout,
|
||||
$reset: () => {
|
||||
clearMqttClient();
|
||||
clearState();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"lib": [
|
||||
"ESNext", "DOM"
|
||||
],
|
||||
"types": ["vite/client"],
|
||||
"types": ["vite/client", "bun"],
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "vue",
|
||||
"baseUrl": ".",
|
||||
|
||||
Reference in New Issue
Block a user