develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
66 changed files with 24100 additions and 1562 deletions
Showing only changes of commit 57903b80b6 - Show all commits

231
bun.lock
View File

@@ -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=="],

View File

@@ -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"
}
}

View File

@@ -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;

View File

@@ -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);
},
},
});

View File

@@ -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>

View 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 };
}

View File

@@ -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 = [];
};

View File

@@ -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);
},
});
}

View File

@@ -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,

View File

@@ -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);
},
});
}

View File

@@ -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;

View File

@@ -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];
};

View 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
View 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
View 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>

View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

59
src/routes/admin/Logs.vue Normal file
View 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>

View 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>

View 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
View 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
View 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
View 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>

View 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>

View 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>

View File

@@ -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',

View File

@@ -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.',

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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">

View File

@@ -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',

View File

@@ -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);

View File

@@ -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({

View File

@@ -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',

View File

@@ -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();

View File

@@ -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();

View File

@@ -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',

View File

@@ -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',

View File

@@ -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: '',
};
}

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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<{

View File

@@ -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>

View File

@@ -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">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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;
}

File diff suppressed because it is too large Load Diff

View 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;
}

View File

@@ -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,
}
}

View File

@@ -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);
}
});
}

View File

@@ -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()}`)
}
}

View File

@@ -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" });
},
);
}

View File

@@ -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);
});
}

View File

@@ -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);
});
}

View File

@@ -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);
}
});
}

View 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" };
},
};

View File

@@ -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;
}

View File

@@ -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);
}),
};

View File

@@ -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();
});
};

View File

@@ -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;
};
}

View File

@@ -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;
});
}

View File

@@ -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();
},
};
});

View File

@@ -8,7 +8,7 @@
"lib": [
"ESNext", "DOM"
],
"types": ["vite/client"],
"types": ["vite/client", "bun"],
"jsx": "preserve",
"jsxImportSource": "vue",
"baseUrl": ".",