develop-updateui #1

Merged
lethdat merged 78 commits from develop-updateui into master 2026-04-02 05:59:23 +00:00
74 changed files with 3927 additions and 1256 deletions
Showing only changes of commit dba9713d96 - Show all commits

View File

@@ -3,7 +3,9 @@
"allow": [
"Bash(bun run build)",
"mcp__ide__getDiagnostics",
"Bash(bun install:*)"
"Bash(bun install:*)",
"Bash(bun preview:*)",
"Bash(curl:*)"
]
}
}

131
bun.lock
View File

@@ -15,6 +15,7 @@
"pinia": "^3.0.4",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.2",
"zod": "^4.3.6",
},
@@ -26,7 +27,7 @@
"unocss": "^66.6.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2",
"wrangler": "^4.62.0",
},
@@ -105,8 +106,12 @@
"@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=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -213,6 +218,12 @@
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
"@intlify/core-base": ["@intlify/core-base@11.2.8", "", { "dependencies": { "@intlify/message-compiler": "11.2.8", "@intlify/shared": "11.2.8" } }, "sha512-nBq6Y1tVkjIUsLsdOjDSJj4AsjvD0UG3zsg9Fyc+OivwlA/oMHSKooUy9tpKj0HqZ+NWFifweHavdljlBLTwdA=="],
"@intlify/message-compiler": ["@intlify/message-compiler@11.2.8", "", { "dependencies": { "@intlify/shared": "11.2.8", "source-map-js": "^1.0.2" } }, "sha512-A5n33doOjmHsBtCN421386cG1tWp5rpOjOYPNsnpjIJbQ4POF0QY2ezhZR9kr0boKwaHjbOifvyQvHj2UTrDFQ=="],
"@intlify/shared": ["@intlify/shared@11.2.8", "", {}, "sha512-l6e4NZyUgv8VyXXH4DbuucFOBmxLF56C/mqh2tvApbzl2Hrhi1aTDcuv5TKdxzfHYmpO3UB0Cz04fgDT9vszfw=="],
"@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=="],
@@ -223,6 +234,12 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@pinia/colada": ["@pinia/colada@0.21.6", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-DppfAYky3Uavlpdx2iZHgd/+ZVPyBGTR+x+kFfAUz8h9l1DIQgf2cw/QZg0RZ4GAUNnKf6Ue6FzfWttwqhZXUQ=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@@ -235,62 +252,40 @@
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.6", "", { "os": "android", "cpu": "arm64" }, "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm" }, "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.6", "", { "os": "none", "cpu": "arm64" }, "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.6", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "x64" }, "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@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/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="],
@@ -477,6 +472,30 @@
"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=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
@@ -533,7 +552,7 @@
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="],
"rolldown": ["rolldown@1.0.0-rc.6", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.6" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-x64": "1.0.0-rc.6", "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
@@ -591,12 +610,14 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"vite": ["vite@8.0.0-beta.16", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.6", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q=="],
"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-i18n": ["vue-i18n@11.2.8", "", { "dependencies": { "@intlify/core-base": "11.2.8", "@intlify/shared": "11.2.8", "@vue/devtools-api": "^6.5.0" }, "peerDependencies": { "vue": "^3.0.0" } }, "sha512-vJ123v/PXCZntd6Qj5Jumy7UBmIuE92VrtdX+AXr+1WzdBHojiBxnAxdfctUFL+/JIN+VQH4BhsfTtiGsvVObg=="],
"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=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
@@ -637,6 +658,8 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
"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=="],
@@ -645,6 +668,8 @@
"unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"vue-i18n/@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="],
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
"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=="],

View File

@@ -20,6 +20,7 @@
"pinia": "^3.0.4",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.27",
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.2",
"zod": "^4.3.6"
},
@@ -31,7 +32,7 @@
"unocss": "^66.6.0",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2",
"wrangler": "^4.62.0"
}

View File

@@ -3,14 +3,25 @@ import 'uno.css';
import PiniaSharedState from './lib/PiniaSharedState';
import { createApp } from './main';
const readAppData = () => {
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
};
async function render() {
const { app, router, queryCache, pinia } = createApp();
pinia.use(PiniaSharedState({enable: true, initialize: true}));
hydrateQueryCache(queryCache, (window as any).$colada || {});
router.isReady().then(() => {
app.mount('body', true)
})
const appData = readAppData();
const { app, router, queryCache, pinia } = createApp(appData.$locale);
pinia.use(PiniaSharedState({ enable: true, initialize: true }));
hydrateQueryCache(queryCache, appData.$colada || {});
Object.entries(appData).forEach(([key, value]) => {
(window as any)[key] = value;
});
await router.isReady();
app.mount('body', true);
}
render().catch((error) => {
console.error('Error during app initialization:', error)
})
console.error('Error during app initialization:', error);
});

View File

@@ -5,26 +5,28 @@ import Video from "@/components/icons/Video.vue";
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
// import Upload from "@/components/icons/Upload.vue";
import { cn } from "@/lib/utils";
import { createStaticVNode, ref } from "vue";
import { computed, createStaticVNode, ref } from "vue";
import { useI18n } from 'vue-i18n';
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";
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
const isNotificationOpen = ref(false);
const { t } = useI18n();
const handleNotificationClick = (event: Event) => {
notificationPopover.value?.toggle(event);
};
const links = [
const links = computed(() => [
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
{ href: "/", label: "Overview", icon: Home, type: "a", className },
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
{ href: "/videos", label: "Videos", icon: Video, type: "a", className },
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
{ href: "/settings", label: "Settings", icon: SettingsIcon, type: "a", 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 },
]);
//v-tooltip="i.label"
@@ -34,7 +36,7 @@ const links = [
<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.label">
<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)"

View File

@@ -3,11 +3,13 @@ import { useUploadQueue } from '@/composables/useUploadQueue';
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
import { useUIState } from '@/stores/uiState';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
const router = useRouter();
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
const uiState = useUIState();
const { t } = useI18n();
const isCollapsed = ref(false);
@@ -28,13 +30,13 @@ const isAllDone = computed(() =>
);
const statusText = computed(() => {
if (isAllDone.value) return 'All done';
if (isAllDone.value) return t('upload.indicator.allDone');
if (isUploading.value) {
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
return `Uploading ${count} file${count !== 1 ? 's' : ''}...`;
return t('upload.indicator.uploading', { count });
}
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
return 'Processing...';
if (pendingCount.value > 0) return t('upload.indicator.waiting', { count: pendingCount.value });
return t('upload.queueItem.status.processing');
});
const isDoneWithErrors = computed(() =>
isAllDone.value &&
@@ -87,7 +89,7 @@ watch(isAllDone, (newItems) => {
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
<p class="text-xs text-slate-400 leading-tight mt-0.5">
{{ completeCount }} of {{ items.length }} complete
{{ t('upload.indicator.completeProgress', { complete: completeCount, total: items.length }) }}
</p>
</div>
@@ -100,17 +102,17 @@ watch(isAllDone, (newItems) => {
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
Start
{{ t('upload.indicator.start') }}
</button>
<button v-else-if="isDoneWithErrors" @click.stop="doneUpload"
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-green-500 hover:bg-green-500/80 text-white rounded-lg transition-all">
View Videos
{{ t('upload.indicator.viewVideos') }}
</button>
<!-- Clear queue -->
<!-- Add more files -->
<button @click.stop="uiState.uploadDialogVisible = true"
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
title="Add more files">
:title="t('upload.indicator.addMoreFiles')">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />

View File

@@ -2,6 +2,7 @@
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { onClickOutside } from '@vueuse/core';
import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
@@ -27,50 +28,57 @@ interface Notification {
const visible = ref(false);
const drawerRef = ref(null);
const { t } = useI18n();
// Mock notifications data
const notifications = ref<Notification[]>([
const notifications = computed<Notification[]>(() => [
{
id: '1',
type: 'video',
title: 'Video processing complete',
message: 'Your video "Summer Vacation 2024" has been successfully processed.',
time: '2 min ago',
title: t('notification.mocks.videoProcessed.title'),
message: t('notification.mocks.videoProcessed.message'),
time: t('notification.time.minutesAgo', { count: 2 }),
read: false,
actionUrl: '/video',
actionLabel: 'View'
actionLabel: t('notification.actions.viewVideo')
},
{
id: '2',
type: 'payment',
title: 'Payment successful',
message: 'Your subscription to Pro Plan has been renewed successfully.',
time: '1 hour ago',
title: t('notification.mocks.paymentSuccess.title'),
message: t('notification.mocks.paymentSuccess.message'),
time: t('notification.time.hoursAgo', { count: 1 }),
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Receipt'
actionLabel: t('notification.actions.viewReceipt')
},
{
id: '3',
type: 'warning',
title: 'Storage almost full',
message: 'You have used 85% of your storage quota.',
time: '3 hours ago',
title: t('notification.mocks.storageWarning.title'),
message: t('notification.mocks.storageWarning.message'),
time: t('notification.time.hoursAgo', { count: 3 }),
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Upgrade'
actionLabel: t('notification.actions.upgradePlan')
},
{
id: '4',
type: 'success',
title: 'Upload successful',
message: 'Your video "Product Demo v2" has been uploaded.',
time: '1 day ago',
title: t('notification.mocks.uploadSuccess.title'),
message: t('notification.mocks.uploadSuccess.message'),
time: t('notification.time.daysAgo', { count: 1 }),
read: true
}
]);
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
const mutableNotifications = ref<Notification[]>([]);
watch(notifications, (value) => {
mutableNotifications.value = value.map(item => ({ ...item }));
}, { immediate: true });
const unreadCount = computed(() => mutableNotifications.value.filter(n => !n.read).length);
const toggle = (event?: Event) => {
console.log(event);
@@ -107,16 +115,16 @@ onClickOutside(drawerRef, (event) => {
});
const handleMarkRead = (id: string) => {
const notification = notifications.value.find(n => n.id === id);
const notification = mutableNotifications.value.find(n => n.id === id);
if (notification) notification.read = true;
};
const handleDelete = (id: string) => {
notifications.value = notifications.value.filter(n => n.id !== id);
mutableNotifications.value = mutableNotifications.value.filter(n => n.id !== id);
};
const handleMarkAllRead = () => {
notifications.value.forEach(n => n.read = true);
mutableNotifications.value.forEach(n => n.read = true);
};
watch(visible, (val) => {
@@ -137,7 +145,7 @@ defineExpose({ toggle });
<!-- Header -->
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900">Notifications</h3>
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
<span v-if="unreadCount > 0"
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
{{ unreadCount }}
@@ -145,14 +153,14 @@ defineExpose({ toggle });
</div>
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
class="text-sm text-primary hover:underline font-medium">
Mark all read
{{ t('notification.actions.markAllRead') }}
</button>
</div>
<!-- Notification List -->
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
<template v-if="notifications.length > 0">
<div v-for="notification in notifications" :key="notification.id"
<template v-if="mutableNotifications.length > 0">
<div v-for="notification in mutableNotifications" :key="notification.id"
class="border-b border-gray-50 last:border-0">
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
@delete="handleDelete" isDrawer />
@@ -162,16 +170,16 @@ defineExpose({ toggle });
<!-- Empty state -->
<div v-else class="py-12 text-center">
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
<p class="text-gray-500 text-sm">No notifications</p>
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
</div>
</div>
<!-- Footer -->
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<router-link to="/notification"
class="block w-full text-center text-sm text-primary font-medium hover:underline"
@click="visible = false">
View all notifications
{{ t('notification.actions.viewAll') }}
</router-link>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
@@ -25,6 +26,8 @@ const emit = defineEmits<{
(e: 'close'): void;
}>();
const { t } = useI18n();
const close = () => {
emit('update:visible', false);
emit('close');
@@ -87,7 +90,7 @@ onBeforeUnmount(() => {
type="button"
class="p-1 rounded-md text-foreground/60 hover:text-foreground hover:bg-muted/50 transition-all"
@click="close"
aria-label="Close"
:aria-label="t('common.close')"
>
<XIcon class="w-4 h-4" />
</button>

View File

@@ -6,9 +6,11 @@ import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils';
import { onBeforeUnmount, watchEffect } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
const { toasts, remove } = useAppToast();
const { t } = useI18n();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -91,7 +93,7 @@ onBeforeUnmount(() => {
type="button"
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
@click="dismiss(t.id)"
aria-label="Dismiss"
:aria-label="t('toast.dismissAria')"
>
<XIcon class="w-4 h-4" />
</button>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { VNode } from 'vue';
interface Trend {
@@ -18,6 +19,8 @@ withDefaults(defineProps<Props>(), {
color: 'primary'
});
const { t } = useI18n();
// const gradients = {
// primary: 'from-primary/20 to-primary/5',
// success: 'from-success/20 to-success/5',
@@ -76,7 +79,7 @@ const iconColors = {
</svg>
{{ Math.abs(trend.value) }}%
</span>
<span class="text-gray-500">vs last month</span>
<span class="text-gray-500">{{ t('overview.stats.trendVsLastMonth') }}</span>
</div>
</div>
</div>

View File

@@ -1,4 +1,5 @@
import { computed, reactive, readonly } from 'vue';
import { getActiveI18n } from '@/i18n';
export type AppConfirmOptions = {
message: string;
@@ -30,12 +31,17 @@ const state = reactive<AppConfirmState>({
});
const requireConfirm = (options: AppConfirmOptions) => {
const i18n = getActiveI18n();
const defaultHeader = i18n?.global.t('confirm.defaultHeader') ?? 'Confirm';
const defaultAccept = i18n?.global.t('confirm.defaultAccept') ?? 'OK';
const defaultReject = i18n?.global.t('confirm.defaultReject') ?? 'Cancel';
state.visible = true;
state.loading = false;
state.message = options.message;
state.header = options.header ?? 'Confirm';
state.acceptLabel = options.acceptLabel ?? 'OK';
state.rejectLabel = options.rejectLabel ?? 'Cancel';
state.header = options.header ?? defaultHeader;
state.acceptLabel = options.acceptLabel ?? defaultAccept;
state.rejectLabel = options.rejectLabel ?? defaultReject;
state.accept = options.accept;
state.reject = options.reject;
};

View File

@@ -1,3 +1,4 @@
import { getActiveI18n } from '@/i18n';
import { computed, ref } from 'vue';
export interface QueueItem {
@@ -40,6 +41,8 @@ const abortItem = (id: string) => {
};
export function useUploadQueue() {
const t = (key: string, params?: Record<string, unknown>) =>
getActiveI18n()?.global.t(key, params) ?? key;
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
@@ -82,12 +85,12 @@ export function useUploadQueue() {
const duplicateCount = allowed.length - fresh.length;
const newItems: QueueItem[] = fresh.map((url) => ({
id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || 'Remote File',
name: url.split('/').pop() || t('upload.queueItem.remoteFileName'),
type: 'remote',
status: 'pending',
progress: 0,
uploaded: '0 MB',
total: 'Unknown',
total: t('upload.queueItem.unknownSize'),
speed: '0 MB/s',
url: url,
activeChunks: 0,
@@ -267,7 +270,7 @@ export function useUploadQueue() {
setTimeout(attempt, 2000);
} else {
item.status = 'error';
reject(new Error(`Failed to upload chunk ${index + 1}`));
reject(new Error(t('upload.errors.chunkUploadFailed', { index: index + 1 })));
}
}
@@ -295,7 +298,7 @@ export function useUploadQueue() {
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Merge failed');
throw new Error(data.error || t('upload.errors.mergeFailed'));
}
item.status = 'complete';
@@ -327,7 +330,8 @@ export function useUploadQueue() {
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${new Intl.NumberFormat(getActiveI18n()?.global.locale.value === 'vi' ? 'vi-VN' : 'en-US').format(value)} ${sizes[i]}`;
};
const totalSize = computed(() => {

7
src/i18n/constants.ts Normal file
View File

@@ -0,0 +1,7 @@
export const supportedLocales = ['en', 'vi'] as const;
export type SupportedLocale = (typeof supportedLocales)[number];
export const defaultLocale: SupportedLocale = 'en';
export const localeCookieKey = 'lang';

75
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,75 @@
import { createI18n as createVueI18n } from 'vue-i18n';
import type { SupportedLocale } from './constants';
import { defaultLocale, supportedLocales } from './constants';
import en from './messages/en';
import vi from './messages/vi';
export const i18nMessages = {
en,
vi,
} as const;
let activeI18n: ReturnType<typeof createI18n> | null = null;
const normalizeLocaleToken = (locale?: string | null): string | undefined => {
if (!locale) return undefined;
return locale
.trim()
.toLowerCase()
.replace('_', '-');
};
export const toSupportedLocale = (locale?: string | null): SupportedLocale | undefined => {
const normalized = normalizeLocaleToken(locale);
if (!normalized) return undefined;
const direct = supportedLocales.find(item => item === normalized);
if (direct) return direct;
const base = normalized.split('-')[0];
return supportedLocales.find(item => item === base);
};
export const normalizeLocale = (locale?: string | null): SupportedLocale => {
return toSupportedLocale(locale) ?? defaultLocale;
};
export const resolveLocaleFromAcceptLanguage = (acceptLanguage?: string | null): SupportedLocale | undefined => {
if (!acceptLanguage) return undefined;
const candidates = acceptLanguage
.split(',')
.map((part) => {
const [rawLocale, ...params] = part.trim().split(';');
const qParam = params.find(param => param.trim().startsWith('q='));
const quality = qParam ? Number.parseFloat(qParam.split('=')[1] ?? '1') : 1;
return {
locale: rawLocale,
quality: Number.isFinite(quality) ? quality : 1,
};
})
.sort((a, b) => b.quality - a.quality);
for (const candidate of candidates) {
const matched = toSupportedLocale(candidate.locale);
if (matched) return matched;
}
return undefined;
};
export const createI18n = (initialLocale?: string | null) => {
const locale = normalizeLocale(initialLocale);
const i18n = createVueI18n({
legacy: false,
locale,
fallbackLocale: defaultLocale,
messages: i18nMessages,
});
activeI18n = i18n;
return i18n;
};
export const getActiveI18n = () => activeI18n;
export type AppI18n = ReturnType<typeof createI18n>;

1045
src/i18n/messages/en.ts Normal file

File diff suppressed because it is too large Load Diff

1045
src/i18n/messages/vi.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { getActiveI18n } from '@/i18n';
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
@@ -51,12 +52,18 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
const getRuntimeLocaleTag = () => {
const locale = getActiveI18n()?.global.locale.value;
return locale === 'vi' ? 'vi-VN' : 'en-US';
};
export const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${new Intl.NumberFormat(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
};
export const formatDuration = (seconds?: number) => {
@@ -73,7 +80,7 @@ export const formatDuration = (seconds?: number) => {
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', {
return new Date(dateString).toLocaleDateString(getRuntimeLocaleTag(), {
month: 'short',
day: 'numeric',
year: 'numeric',

View File

@@ -1,17 +1,31 @@
import { PiniaColada, useQueryCache } from '@pinia/colada';
import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as SSRHead } from "@unhead/vue/server";
import { createPinia } from "pinia";
import { createHead as CSRHead } from '@unhead/vue/client';
import { createHead as SSRHead } from '@unhead/vue/server';
import { createPinia } from 'pinia';
import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router';
import { createI18n, normalizeLocale } from './i18n';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import createAppRouter from './routes';
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
export function createApp() {
const bodyClass = ':uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen';
const getSerializedAppData = () => {
if (typeof document === 'undefined') return {} as Record<string, any>;
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
};
export function createApp(initialLocale?: string | null) {
const pinia = createPinia();
const app = createSSRApp(withErrorBoundary(RouterView));
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
const appData = !import.meta.env.SSR ? getSerializedAppData() : ({} as Record<string, any>);
const resolvedInitialLocale = initialLocale
?? (!import.meta.env.SSR ? appData.$locale : undefined)
?? undefined;
const i18n = createI18n(normalizeLocale(resolvedInitialLocale));
app.use(head);
app.directive('nh', {
@@ -20,11 +34,12 @@ export function createApp() {
}
});
app.use(pinia);
app.use(i18n);
app.use(PiniaColada, {
pinia,
plugins: [
(context) => {
// console.log("PiniaColada plugin initialized for store:", context);
() => {
// reserved for query plugins
}
],
queryOptions: {
@@ -32,19 +47,20 @@ export function createApp() {
refetchOnWindowFocus: false,
ssrCatchError: true,
}
// optional options
})
// app.use(vueSWR({ revalidateOnFocus: false }));
});
const queryCache = useQueryCache();
const router = createAppRouter();
app.use(router);
if (!import.meta.env.SSR) {
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
Object.entries(appData).forEach(([key, value]) => {
(window as any)[key] = value;
});
if ((window as any).$p) {
pinia.state.value = (window as any).$p;
}
}
return { app, router, head, pinia, bodyClass, queryCache };
return { app, router, head, pinia, bodyClass, queryCache, i18n };
}

View File

@@ -1,12 +1,15 @@
<template>
<vue-head :input="{title: '404 - Page Not Found'}"/>
<vue-head :input="{ title: t('notFound.headTitle') }" />
<div class="mx-auto text-center mt-20 flex flex-col items-center gap-4">
<h1>404 - Page Not Found</h1>
<p>The page you are looking for does not exist.</p>
<router-link class="btn btn-primary" to="/">Go back to Home</router-link>
<h1>{{ t('notFound.title') }}</h1>
<p>{{ t('notFound.description') }}</p>
<router-link class="btn btn-primary" to="/">{{ t('notFound.backHome') }}</router-link>
</div>
</template>
<script setup lang="ts">
import { VueHead } from "@/components/VueHead";
import { VueHead } from '@/components/VueHead';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

View File

@@ -2,16 +2,16 @@
<div class="w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2">
Enter your email address and we'll send you a link to reset your password.
{{ t('auth.forgot.description') }}
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.forgot.email') }}</label>
<AppInput id="email" v-model="form.email" type="email" :placeholder="t('auth.forgot.placeholders.email')" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<AppButton type="submit" class="w-full">Send Reset Link</AppButton>
<AppButton type="submit" class="w-full">{{ t('auth.forgot.sendResetLink') }}</AppButton>
<div class="text-center mt-2">
<router-link to="/login" replace
@@ -20,7 +20,7 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Back to Sign in
{{ t('auth.forgot.backToSignIn') }}
</router-link>
</div>
</form>
@@ -31,9 +31,11 @@
import { client } from '@/api/client';
import { useAppToast } from '@/composables/useAppToast';
import { reactive } from 'vue';
import { useI18n } from 'vue-i18n';
import { z } from 'zod';
const toast = useAppToast();
const { t } = useI18n();
const form = reactive({
email: ''
@@ -42,7 +44,7 @@ const form = reactive({
const errors = reactive<{ email?: string }>({});
const schema = z.object({
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
email: z.string().min(1, { message: t('auth.forgot.errors.emailRequired') }).email({ message: t('auth.forgot.errors.emailInvalid') })
});
const onFormSubmit = () => {
@@ -59,10 +61,20 @@ const onFormSubmit = () => {
client.auth.forgotPasswordCreate({ email: form.email })
.then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
toast.add({
severity: 'success',
summary: t('auth.forgot.toast.successSummary'),
detail: t('auth.forgot.toast.successDetail'),
life: 3000,
});
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
toast.add({
severity: 'error',
summary: t('auth.forgot.toast.errorSummary'),
detail: error.message || t('auth.forgot.toast.errorDetail'),
life: 3000,
});
});
};
</script>

View File

@@ -5,12 +5,12 @@
class=":uno: w-full shadow-xl bg-white p-6 rounded-xl relative before:(content-[''] absolute inset-[-5px] translate-0 z-[-1] opacity-50 rounded-xl bg-[linear-gradient(135deg,var(--glow-stop-1)_0,var(--glow-stop-2)_25%,var(--glow-stop-3)_50%,var(--glow-stop-4)_75%,var(--glow-stop-5)_100%)] animate-[glow-enter-blur_1s_ease_.5s_both]) after:(content-[''] absolute inset-[-1px] translate-0 z-[-1] opacity-50 rounded-xl bg-[linear-gradient(135deg,transparent_0,transparent_34%,transparent_49%,#fff_57%,#fff_64%,var(--glow-stop-1)_66%,var(--glow-stop-2)_75%,var(--glow-stop-3)_83%,var(--glow-stop-4)_92%,var(--glow-stop-5)_100%)] bg-[length:300%_300%] bg-[position:0_0] bg-no-repeat transition-background-position duration-800 ease animate-[glow-enter-stroke_.5s_ease_.5s_both])">
<div class="mb-6">
<h2 class="text-xl font-medium text-gray-900">
{{ content[route.name as keyof typeof content]?.title || '' }}
{{ content[route.name as keyof typeof content.value]?.title || '' }}
</h2>
<vue-head :input="{
title: content[route.name as keyof typeof content]?.headTitle || 'Authentication',
title: content.value[route.name as keyof typeof content.value]?.headTitle || t('app.name'),
meta: [
{ name: 'description', content: content[route.name as keyof typeof content]?.subtitle || '' }
{ name: 'description', content: content.value[route.name as keyof typeof content.value]?.subtitle || '' }
]
}" />
</div>
@@ -18,29 +18,33 @@
</div>
<router-link to="/" class="inline-flex items-center justify-center w-6 h-6 mt-10 group w-full">
<img class="w-6 h-6" src="/apple-touch-icon.png" alt="Logo" />&ensp;<span
class="text-[#6a6a6a] font-medium group-hover:text-gray-900">EcoStream</span>
class="text-[#6a6a6a] font-medium group-hover:text-gray-900">{{ t('app.name') }}</span>
</router-link>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
const route = useRoute();
const content = {
const { t } = useI18n();
const content = computed(() => ({
login: {
headTitle: "Login to your account",
title: 'Sign in to your dashboard',
subtitle: 'Please enter your details to sign in.'
headTitle: t('auth.layout.login.headTitle'),
title: t('auth.layout.login.title'),
subtitle: t('auth.layout.login.subtitle')
},
signup: {
headTitle: "Create your account",
title: 'Create your account',
subtitle: 'Please fill in the information to create your account.'
headTitle: t('auth.layout.signup.headTitle'),
title: t('auth.layout.signup.title'),
subtitle: t('auth.layout.signup.subtitle')
},
forgot: {
title: 'Forgot your password?',
subtitle: "Enter your email address and we'll send you a link to reset your password.",
headTitle: "Reset your password"
title: t('auth.layout.forgot.title'),
subtitle: t('auth.layout.forgot.subtitle'),
headTitle: t('auth.layout.forgot.headTitle')
}
}
}));
</script>

View File

@@ -2,17 +2,17 @@
<div class="w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
<AppInput id="email" v-model="form.email" type="text" placeholder="Enter your email"
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.login.email') }}</label>
<AppInput id="email" v-model="form.email" type="text" :placeholder="t('auth.signup.placeholders.email')"
:disabled="auth.loading" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<label for="password" class="text-sm font-medium text-gray-700">{{ t('auth.login.password') }}</label>
<div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
placeholder="Enter your password" :disabled="auth.loading" />
:placeholder="t('auth.signup.placeholders.password')" :disabled="auth.loading" />
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
@@ -36,17 +36,16 @@
<input id="remember-me" v-model="form.rememberMe" type="checkbox"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
:disabled="auth.loading" />
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
<label for="remember-me" class="text-sm text-gray-900">{{ t('auth.login.signIn') }}</label>
</div>
<div class="text-sm">
<router-link to="/forgot"
class="text-blue-600 hover:text-blue-500 hover:underline">Forgot
password?</router-link>
class="text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.forgotPassword') }}</router-link>
</div>
</div>
<AppButton type="submit" :loading="auth.loading" class="w-full">
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
{{ auth.loading ? `${t('common.loading')}...` : t('auth.login.signIn') }}
</AppButton>
<div class="relative">
@@ -54,7 +53,7 @@
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
<span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
</div>
</div>
@@ -64,13 +63,13 @@
<path
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
Google
{{ t('auth.login.google') }}
</AppButton>
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
<p class="text-center text-sm text-gray-600">
Don't have an account?
{{ t('auth.login.noAccount') }}
<router-link to="/sign-up"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.signUp') }}</router-link>
</p>
</div>
</form>
@@ -81,11 +80,13 @@
import { useAuthStore } from '@/stores/auth';
import { useAppToast } from '@/composables/useAppToast';
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { z } from 'zod';
const toast = useAppToast();
const auth = useAuthStore();
const showPassword = ref(false);
const { t } = useI18n();
const form = reactive({
email: '',
@@ -96,8 +97,8 @@ const form = reactive({
const errors = reactive<{ email?: string; password?: string }>({});
const schema = z.object({
email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
email: z.string().min(1, { message: t('auth.login.errors.emailRequired') }),
password: z.string().min(1, { message: t('auth.login.errors.passwordRequired') })
});
watch(() => auth.error, (newError) => {

View File

@@ -2,22 +2,22 @@
<div class="w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
<AppInput id="name" v-model="form.name" placeholder="John Doe" />
<label for="name" class="text-sm font-medium text-gray-700">{{ t('auth.signup.fullName') }}</label>
<AppInput id="name" v-model="form.name" :placeholder="t('auth.signup.placeholders.name')" />
<p v-if="errors.name" class="text-xs text-red-500 mt-0.5">{{ errors.name }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.signup.email') }}</label>
<AppInput id="email" v-model="form.email" type="email" :placeholder="t('auth.signup.placeholders.email')" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<label for="password" class="text-sm font-medium text-gray-700">{{ t('auth.signup.password') }}</label>
<div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
placeholder="Create a password" />
:placeholder="t('auth.signup.placeholders.password')" />
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
@@ -33,16 +33,16 @@
</svg>
</button>
</div>
<small class="text-gray-500">Must be at least 8 characters.</small>
<small class="text-gray-500">{{ t('auth.signup.passwordHint') }}</small>
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div>
<AppButton type="submit" class="w-full">Create Account</AppButton>
<AppButton type="submit" class="w-full">{{ t('auth.signup.createAccount') }}</AppButton>
<p class="mt-4 text-center text-sm text-gray-600">
Already have an account?
{{ t('auth.signup.alreadyHave') }}
<router-link to="/login"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.signup.signIn') }}</router-link>
</p>
</form>
</div>
@@ -51,10 +51,12 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { reactive, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { z } from 'zod';
const auth = useAuthStore();
const showPassword = ref(false);
const { t } = useI18n();
const form = reactive({
name: '',
@@ -65,9 +67,9 @@ const form = reactive({
const errors = reactive<{ name?: string; email?: string; password?: string }>({});
const schema = z.object({
name: z.string().min(1, { message: 'Name is required.' }),
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
name: z.string().min(1, { message: t('auth.signup.errors.nameRequired') }),
email: z.string().min(1, { message: t('auth.signup.errors.emailRequired') }).email({ message: t('auth.signup.errors.emailInvalid') }),
password: z.string().min(8, { message: t('auth.signup.errors.passwordMin') })
});
const onFormSubmit = () => {

View File

@@ -1,6 +1,5 @@
<template>
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex">
<!-- <div class="absolute inset-0 bg-grid-pattern opacity-[0.4] -z-10"></div> -->
<div
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
</div>
@@ -12,13 +11,12 @@
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center">
<h1
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards">
Video infrastructure for <br>
<span class="text-gradient">modern internet.</span>
{{ t('home.hero.titleLine1') }} <br>
<span class="text-gradient">{{ t('home.hero.titleLine2') }}</span>
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50">
Seamlessly host, encode, and stream video with our developer-first API.
Optimized for speed, built for scale.
{{ t('home.hero.subtitle') }}
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
@@ -26,7 +24,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580">
<path d="M56 284v-560L560 4 56 284z" fill="#fff" />
</svg>&nbsp;
Get Started
{{ t('home.hero.getStarted') }}
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="28" viewBox="0 0 596 468">
@@ -34,10 +32,10 @@
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
fill="#14a74b" />
<path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s24-11 24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="#fff" />
</svg>&nbsp;
Upload video
{{ t('home.hero.uploadVideo') }}
</RouterLink>
</div>
</div>
@@ -45,9 +43,8 @@
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video
infrastructure.</p>
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ t('home.features.heading') }}</h2>
<p class="text-lg text-slate-500">{{ t('home.features.subtitle') }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
@@ -62,9 +59,8 @@
fill="#059669" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region
selection ensures the lowest latency for every viewer.</p>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.global.title') }}</h3>
<p class="text-slate-500 max-w-md">{{ t('home.features.global.description') }}</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
@@ -86,32 +82,29 @@
fill="#fff" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers
with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
<h3 class="text-xl font-bold mb-2">{{ t('home.features.live.title') }}</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">{{ t('home.features.live.description') }}</p>
<!-- Visual -->
<div
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">Live Status</span>
<span class="text-slate-500">{{ t('home.features.live.status') }}</span>
<span
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> {{ t('home.features.live.onAir') }}</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span
class="text-white">6000 kbps</span></div>
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span
class="text-white">60</span></div>
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span
class="text-brand-400">~2s</span></div>
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.bitrate') }}</span> <span
class="text-white">{{ t('home.features.live.bitrateValue') }}</span></div>
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.fps') }}</span> <span
class="text-white">{{ t('home.features.live.fpsValue') }}</span></div>
<div class="flex justify-between"><span class="text-slate-400">{{ t('home.features.live.latency') }}</span> <span
class="text-brand-400">{{ t('home.features.live.latencyValue') }}</span></div>
</div>
</div>
</div>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
@@ -125,12 +118,10 @@
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.
</p>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.encoding.title') }}</h3>
<p class="text-slate-500 text-sm">{{ t('home.features.encoding.description') }}</p>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
@@ -141,14 +132,12 @@
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and
more.</p>
<h3 class="text-xl font-bold text-slate-900 mb-2">{{ t('home.features.analytics.title') }}</h3>
<p class="text-slate-500 text-sm">{{ t('home.features.analytics.description') }}</p>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
@@ -158,7 +147,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == t('home.pricing.pro.tag') ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:style="{ background: pack.bg }">
<div v-if="pack.tag"
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
@@ -167,7 +156,7 @@
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">/mo</span>
<span class="text-slate-500">{{ t('home.pricing.perMonth') }}</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
@@ -175,7 +164,7 @@
class="fas fa-check text-brand-500" /> {{ value }}</li>
</ul>
<router-link to="/sign-up"
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == t('home.pricing.pro.tag') ? 'btn-primary' : 'btn-outline-primary')">{{
pack.buttonText }}</router-link>
</div>
</div>
@@ -183,49 +172,45 @@
</section>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { cn } from '@/lib/utils';
const pricing = {
title: "Simple, transparent pricing",
subtitle: "Choose the plan that fits your needs. No hidden fees.",
const { t, tm } = useI18n();
const getFeatureList = (key: string): string[] => {
const localized = tm(key);
return Array.isArray(localized) ? localized.map((item) => String(item)) : [];
};
const pricing = computed(() => ({
title: t('home.pricing.title'),
subtitle: t('home.pricing.subtitle'),
packs: [
{
name: "Hobby",
price: "$0",
features: [
"Unlimited upload",
"1 Hour of Storage",
"Standard Support",
],
buttonText: "Start Free",
tag: "",
bg: "#f9fafb",
name: t('home.pricing.hobby.name'),
price: '$0',
features: getFeatureList('home.pricing.hobby.features'),
buttonText: t('home.pricing.hobby.button'),
tag: '',
bg: '#f9fafb',
},
{
name: "Pro",
price: "$29",
features: [
"Ads free player",
"Support M3U8",
"Unlimited upload",
"Custom ads"
],
buttonText: "Get Started",
tag: "POPULAR",
bg: "#eff6ff",
name: t('home.pricing.pro.name'),
price: '$29',
features: getFeatureList('home.pricing.pro.features'),
buttonText: t('home.pricing.pro.button'),
tag: t('home.pricing.pro.tag'),
bg: '#eff6ff',
},
{
name: "Scale",
price: "$99",
features: [
"5 TB Bandwidth",
"500 Hours Storage",
"Priority Support"
],
buttonText: "Contact Sales",
tag: "Best Value",
bg: "#eef4f7",
name: t('home.pricing.scale.name'),
price: '$99',
features: getFeatureList('home.pricing.scale.features'),
buttonText: t('home.pricing.scale.button'),
tag: t('home.pricing.scale.tag'),
bg: '#eef4f7',
}
]
}
}));
</script>

View File

@@ -5,21 +5,21 @@
<div class="flex items-center justify-between h-16">
<router-link to="/" class="flex items-center gap-2 cursor-pointer">
<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
<span class="font-bold text-xl tracking-tight text-slate-900">{{ t('app.name') }}</span>
</router-link>
<div class="hidden md:flex items-center space-x-8">
<a href="#features"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">{{ t('home.nav.features') }}</a>
<a href="#pricing"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">{{ t('home.nav.pricing') }}</a>
</div>
<div class="hidden md:flex items-center gap-4">
<RouterLink to="/login"
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">{{ t('home.nav.login') }}
</RouterLink>
<RouterLink to="/sign-up"
class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
Start for free
{{ t('home.nav.startFree') }}
</RouterLink>
</div>
</div>
@@ -38,47 +38,49 @@
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
</div>
<span class="font-bold text-lg text-slate-900">EcoStream</span>
<span class="font-bold text-lg text-slate-900">{{ t('app.name') }}</span>
</div>
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for
developers.</p>
<p class="text-slate-500 text-sm max-w-xs">{{ t('home.footer.description') }}</p>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.product') }}</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">Features</a></li>
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productFeatures') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productPricing') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productShowcase') }}</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.company') }}</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">About</a></li>
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyAbout') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyBlog') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyCareers') }}</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.legal') }}</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><router-link to="/privacy" class="hover:text-brand-600">Privacy</router-link></li>
<li><router-link to="/terms" class="hover:text-brand-600">Terms</router-link></li>
<li><router-link to="/privacy" class="hover:text-brand-600">{{ t('home.footer.privacy') }}</router-link></li>
<li><router-link to="/terms" class="hover:text-brand-600">{{ t('home.footer.terms') }}</router-link></li>
</ul>
</div>
</div>
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
&copy; 2026 EcoStream Inc. All rights reserved.
{{ t('home.footer.copyright', { year: 2026 }) }}
</div>
</div>
</footer>
<Head>
<title>EcoStream - Video infrastructure for modern internet</title>
<title>{{ t('home.head.title') }}</title>
<meta name="description"
content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
:content="t('home.head.description')" />
</Head>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>

View File

@@ -20,42 +20,53 @@
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Privacy Policy - Ecostream";
const description = "Read about Ecostream's commitment to protecting your privacy and data security.";
const pageContent = {
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useHead } from '@unhead/vue';
const { t } = useI18n();
const pageContent = computed(() => {
const title = t('legal.privacy.title');
const description = t('legal.privacy.description');
return {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'twitter:title', content: title },
{ property: 'twitter:description', content: description },
{ property: 'twitter:image', content: 'https://Ecostream.com/thumb.png' }
]
},
data: {
pageHeading: "Legal & Privacy Policy",
pageSubheading: "Legal & Privacy Policy",
description: "Our legal and privacy policy.",
list: [{
heading: "1. Privacy Policy",
text: "At Ecostream, we take your privacy seriously. This policy describes how we collect, use, and protect your personal information. We only collect information that is necessary for the operation of our service, including email addresses for account creation and payment information for subscription processing."
pageHeading: t('legal.privacy.pageHeading'),
pageSubheading: t('legal.privacy.pageSubheading'),
description: t('legal.privacy.pageDescription'),
list: [
{
heading: t('legal.privacy.sections.policyTitle'),
text: t('legal.privacy.sections.policyText')
},
{
heading: "2. Data Collection",
text: "We collect data such as IP addresses, browser types, and access times to analyze trends and improve our service. Uploaded content is stored securely and is only accessed as required for the delivery of our hosting services."
heading: t('legal.privacy.sections.dataCollectionTitle'),
text: t('legal.privacy.sections.dataCollectionText')
},
{
heading: "3. Cookie Policy",
text: "We use cookies to maintain user sessions and preferences. By using our website, you consent to the use of cookies in accordance with this policy."
heading: t('legal.privacy.sections.cookieTitle'),
text: t('legal.privacy.sections.cookieText')
},
{
heading: "4. DMCA & Copyright",
text: "Ecostream respects the intellectual property rights of others. We respond to notices of alleged copyright infringement in accordance with the Digital Millennium Copyright Act (DMCA). Please report any copyright violations to our support team."
}]
heading: t('legal.privacy.sections.dmcaTitle'),
text: t('legal.privacy.sections.dmcaText')
}
}
useHead(pageContent.head);
]
}
};
});
useHead(() => pageContent.value.head);
</script>

View File

@@ -20,48 +20,57 @@
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Terms and Conditions - Ecostream";
const description = "Read Ecostream's terms and conditions for using our video hosting and streaming services.";
const pageContent = {
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useHead } from '@unhead/vue';
const { t } = useI18n();
const pageContent = computed(() => {
const title = t('legal.terms.title');
const description = t('legal.terms.description');
return {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
{ name: 'description', content: description },
{ property: 'og:title', content: title },
{ property: 'og:description', content: description },
{ property: 'twitter:title', content: title },
{ property: 'twitter:description', content: description },
{ property: 'twitter:image', content: 'https://Ecostream.com/thumb.png' }
]
},
data: {
pageHeading: "Terms and Conditions Details",
pageSubheading: "Terms and Conditions",
description: "Our terms and conditions set forth important guidelines and rules for using Ecostream's services.",
pageHeading: t('legal.terms.pageHeading'),
pageSubheading: t('legal.terms.pageSubheading'),
description: t('legal.terms.pageDescription'),
list: [
{
heading: "1. Acceptance of Terms",
text: "By accessing and using Ecostream, you accept and agree to be bound by the terms and provision of this agreement."
heading: t('legal.terms.sections.acceptanceTitle'),
text: t('legal.terms.sections.acceptanceText')
},
{
heading: "2. Service Usage",
text: "You agree to use our service only for lawful purposes. You are prohibited from posting or transmitting any unlawful, threatening, libelous, defamatory, obscene, or profane material. We reserve the right to terminate accounts that violate these terms."
heading: t('legal.terms.sections.usageTitle'),
text: t('legal.terms.sections.usageText')
},
{
heading: "3. Content Ownership",
text: "You retain all rights and ownership of the content you upload to Ecostream. However, by uploading content, you grant us a license to host, store, and display the content as necessary to provide our services."
heading: t('legal.terms.sections.ownershipTitle'),
text: t('legal.terms.sections.ownershipText')
},
{
heading: "4. Limitation of Liability",
text: "Ecostream shall not be liable for any direct, indirect, incidental, special, or consequential damages resulting from the use or inability to use our service."
heading: t('legal.terms.sections.liabilityTitle'),
text: t('legal.terms.sections.liabilityText')
},
{
heading: "5. Changes to Terms",
text: "We reserve the right to modify these terms at any time. Your continued use of the service after any such changes constitutes your acceptance of the new terms."
heading: t('legal.terms.sections.changesTitle'),
text: t('legal.terms.sections.changesText')
}
]
}
}
useHead(pageContent.head);
};
});
useHead(() => pageContent.value.head);
</script>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import NotificationActions from './components/NotificationActions.vue';
import NotificationList from './components/NotificationList.vue';
import NotificationTabs from './components/NotificationTabs.vue';
@@ -20,72 +21,74 @@ interface Notification {
const loading = ref(false);
const activeTab = ref('all');
const { t } = useI18n();
// Mock notifications data
const notifications = ref<Notification[]>([
{
id: '1',
type: 'video',
title: 'Video processing complete',
message: 'Your video "Summer Vacation 2024" has been successfully processed and is now ready to stream.',
time: '2 minutes ago',
title: t('notification.mocks.videoProcessed.title'),
message: t('notification.mocks.videoProcessed.message'),
time: t('notification.time.minutesAgo', { count: 2 }),
read: false,
actionUrl: '/video',
actionLabel: 'View video'
actionLabel: t('notification.actions.viewVideo')
},
{
id: '2',
type: 'payment',
title: 'Payment successful',
message: 'Your subscription to Pro Plan has been renewed successfully. Next billing date: Feb 25, 2026.',
time: '1 hour ago',
title: t('notification.mocks.paymentSuccess.title'),
message: t('notification.mocks.paymentSuccess.message'),
time: t('notification.time.hoursAgo', { count: 1 }),
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'View receipt'
actionLabel: t('notification.actions.viewReceipt')
},
{
id: '3',
type: 'warning',
title: 'Storage almost full',
message: 'You have used 85% of your storage quota. Consider upgrading your plan for more space.',
time: '3 hours ago',
title: t('notification.mocks.storageWarning.title'),
message: t('notification.mocks.storageWarning.message'),
time: t('notification.time.hoursAgo', { count: 3 }),
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Upgrade plan'
actionLabel: t('notification.actions.upgradePlan')
},
{
id: '4',
type: 'success',
title: 'Upload successful',
message: 'Your video "Product Demo v2" has been uploaded successfully.',
time: '1 day ago',
title: t('notification.mocks.uploadSuccess.title'),
message: t('notification.mocks.uploadSuccess.message'),
time: t('notification.time.daysAgo', { count: 1 }),
read: true
},
{
id: '5',
type: 'system',
title: 'Scheduled maintenance',
message: 'We will perform scheduled maintenance on Jan 30, 2026 from 2:00 AM to 4:00 AM UTC.',
time: '2 days ago',
title: t('notification.mocks.maintenance.title'),
message: t('notification.mocks.maintenance.message'),
time: t('notification.time.daysAgo', { count: 2 }),
read: true
},
{
id: '6',
type: 'info',
title: 'New feature available',
message: 'We just launched video analytics! Track your video performance with detailed insights.',
time: '3 days ago',
title: t('notification.mocks.newFeature.title'),
message: t('notification.mocks.newFeature.message'),
time: t('notification.time.daysAgo', { count: 3 }),
read: true,
actionUrl: '/video',
actionLabel: 'Try it now'
actionLabel: t('notification.actions.tryNow')
}
]);
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
const tabs = computed(() => [
{ key: 'all', label: 'All', icon: 'i-lucide-inbox', count: notifications.value.length },
{ key: 'unread', label: 'Unread', icon: 'i-lucide-bell-dot', count: unreadCount.value },
{ key: 'video', label: 'Videos', icon: 'i-lucide-video', count: notifications.value.filter(n => n.type === 'video').length },
{ key: 'payment', label: 'Payments', icon: 'i-lucide-credit-card', count: notifications.value.filter(n => n.type === 'payment').length }
{ key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notifications.value.length },
{ key: 'unread', label: t('notification.tabs.unread'), icon: 'i-lucide-bell-dot', count: unreadCount.value },
{ key: 'video', label: t('notification.tabs.videos'), icon: 'i-lucide-video', count: notifications.value.filter(n => n.type === 'video').length },
{ key: 'payment', label: t('notification.tabs.payments'), icon: 'i-lucide-credit-card', count: notifications.value.filter(n => n.type === 'payment').length }
]);
const filteredNotifications = computed(() => {
@@ -94,8 +97,6 @@ const filteredNotifications = computed(() => {
return notifications.value.filter(n => n.type === activeTab.value);
});
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
const handleMarkRead = (id: string) => {
const notification = notifications.value.find(n => n.id === id);
if (notification) notification.read = true;
@@ -117,11 +118,11 @@ const handleClearAll = () => {
<template>
<div>
<PageHeader
title="Notifications"
description="Stay updated with your latest activities and alerts."
:title="t('notification.title')"
:description="t('notification.subtitle')"
:breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Notifications' }
{ label: t('pageHeader.dashboard'), to: '/' },
{ label: t('nav.notification') }
]"
/>
<div class="w-full max-w-4xl mx-auto mt-6">

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
interface Props {
loading?: boolean;
totalCount: number;
@@ -10,6 +12,8 @@ const emit = defineEmits<{
markAllRead: [];
clearAll: [];
}>();
const { t } = useI18n();
</script>
<template>
@@ -18,11 +22,11 @@ const emit = defineEmits<{
<div class="stats flex items-center gap-4">
<div class="flex items-center gap-2 text-sm">
<span class="i-lucide-bell w-4 h-4 text-gray-400"></span>
<span class="text-gray-600">{{ totalCount }} notifications</span>
<span class="text-gray-600">{{ t('notification.stats.total', { count: totalCount }) }}</span>
</div>
<div v-if="unreadCount > 0" class="flex items-center gap-2 text-sm">
<span class="w-2 h-2 rounded-full bg-primary animate-pulse"></span>
<span class="text-primary font-medium">{{ unreadCount }} unread</span>
<span class="text-primary font-medium">{{ t('notification.stats.unread', { count: unreadCount }) }}</span>
</div>
</div>
</div>
@@ -36,7 +40,7 @@ const emit = defineEmits<{
hover:bg-gray-100 rounded-lg transition-colors flex items-center gap-2"
>
<span class="i-lucide-check-check w-4 h-4"></span>
Mark all read
{{ t('notification.actions.markAllRead') }}
</button>
<button
v-if="totalCount > 0"
@@ -46,7 +50,7 @@ const emit = defineEmits<{
hover:bg-red-50 rounded-lg transition-colors flex items-center gap-2"
>
<span class="i-lucide-trash w-4 h-4"></span>
Clear all
{{ t('notification.actions.clearAll') }}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
@@ -31,6 +32,8 @@ const emit = defineEmits<{
delete: [id: string];
}>();
const { t } = useI18n();
const iconComponent = computed(() => {
const icons: Record<string, any> = {
info: InfoIcon,
@@ -70,12 +73,10 @@ const bgClass = computed(() => {
'flex items-start gap-4 group cursor-pointer relative',
bgClass
]" @click="emit('markRead', notification.id)">
<!-- Icon -->
<div v-if="!isDrawer" class="flex-shrink-0 w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
<component :is="iconComponent" :class="[iconColorClass, 'w-5 h-5']" />
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<h4 :class="['font-semibold text-gray-900', !notification.read && 'text-primary-700']">
@@ -85,30 +86,27 @@ const bgClass = computed(() => {
</div>
<p class="text-sm text-gray-600 mt-1 line-clamp-2">{{ notification.message }}</p>
<!-- Action Button -->
<router-link v-if="notification.actionUrl" :to="notification.actionUrl"
class="inline-flex items-center gap-1 text-sm text-primary font-medium mt-2 hover:underline">
{{ notification.actionLabel || 'View Details' }}
{{ notification.actionLabel || t('notification.item.viewDetails') }}
<ArrowRightIcon class="w-4 h-4" />
</router-link>
</div>
<!-- Actions -->
<div v-if="!isDrawer"
class="flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1">
<button v-if="!notification.read" @click.stop="emit('markRead', notification.id)"
class="p-2 rounded-lg hover:bg-gray-200 text-gray-500 hover:text-gray-700 transition-colors"
title="Mark as read">
:title="t('notification.item.markAsRead')">
<CheckMarkIcon class="w-4 h-4" />
</button>
<button @click.stop="emit('delete', notification.id)"
class="p-2 rounded-lg hover:bg-red-100 text-gray-500 hover:text-red-600 transition-colors"
title="Delete">
:title="t('notification.item.delete')">
<TrashIcon class="w-4 h-4" />
</button>
</div>
<!-- Unread indicator -->
<div v-if="!notification.read"
class="absolute left-2 top-1/10 -translate-y-1/2 w-2 h-2 rounded-full bg-primary">
</div>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import NotificationItem from './NotificationItem.vue';
interface Notification {
@@ -22,11 +23,12 @@ const emit = defineEmits<{
markRead: [id: string];
delete: [id: string];
}>();
const { t } = useI18n();
</script>
<template>
<div class="notification-list space-y-3">
<!-- Loading skeleton -->
<template v-if="loading">
<div
v-for="i in 5"
@@ -43,7 +45,6 @@ const emit = defineEmits<{
</div>
</template>
<!-- Notification items -->
<template v-else-if="notifications.length > 0">
<NotificationItem
v-for="notification in notifications"
@@ -54,7 +55,6 @@ const emit = defineEmits<{
/>
</template>
<!-- Empty state -->
<div
v-else
class="py-16 text-center"
@@ -62,8 +62,8 @@ const emit = defineEmits<{
<div class="w-20 h-20 mx-auto mb-4 rounded-full bg-gray-100 flex items-center justify-center">
<span class="i-lucide-bell-off w-10 h-10 text-gray-400"></span>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-1">No notifications</h3>
<p class="text-gray-500">You're all caught up! Check back later.</p>
<h3 class="text-lg font-semibold text-gray-900 mb-1">{{ t('notification.empty.title') }}</h3>
<p class="text-gray-500">{{ t('notification.empty.subtitle') }}</p>
</div>
</div>
</template>

View File

@@ -2,6 +2,7 @@
import { client, type ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import NameGradient from './components/NameGradient.vue';
import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue';
@@ -9,20 +10,19 @@ import StatsOverview from './components/StatsOverview.vue';
const loading = ref(true);
const recentVideos = ref<ModelVideo[]>([]);
const { t } = useI18n();
// Mock stats data (in real app, fetch from API)
const stats = ref({
totalVideos: 0,
totalViews: 0,
storageUsed: 0,
storageLimit: 10737418240, // 10GB in bytes
storageLimit: 10737418240,
uploadsThisMonth: 0
});
const fetchDashboardData = async () => {
loading.value = true;
try {
// Fetch recent videos
const response = await client.videos.videosList({ page: 1, limit: 5 });
const body = response.data as any;
@@ -34,7 +34,6 @@ const fetchDashboardData = async () => {
stats.value.totalVideos = body.length;
}
// Calculate mock stats
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
@@ -52,25 +51,20 @@ const fetchDashboardData = async () => {
onMounted(() => {
fetchDashboardData();
});
</script>
<template>
<div class="dashboard-overview">
<PageHeader :title="NameGradient" description="Welcome back, Here's what's happening with your videos." :breadcrumbs="[
{ label: 'Dashboard' }
<PageHeader :title="NameGradient" :description="t('overview.pageHeaderDescription')" :breadcrumbs="[
{ label: t('pageHeader.dashboard') }
]" />
<!-- Stats Grid -->
<StatsOverview :loading="loading" :stats="stats" />
<!-- Quick Actions -->
<QuickActions :loading="loading" />
<!-- Recent Videos -->
<RecentVideos :loading="loading" :videos="recentVideos" />
<!-- Storage Usage -->
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
</div>
</template>

View File

@@ -1,10 +1,12 @@
<template>
<div class="text-3xl font-bold text-gray-900 mb-1">
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">Hello, {{ auth.user?.username }}</span>
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ t('overview.nameGradient.hello', { name: auth.user?.username || t('app.name') }) }}</span>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useI18n } from 'vue-i18n';
const auth = useAuthStore()
const auth = useAuthStore();
const { t } = useI18n();
</script>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import Chart from '@/components/icons/Chart.vue';
import Credit from '@/components/icons/Credit.vue';
import Upload from '@/components/icons/Upload.vue';
@@ -6,6 +8,7 @@ import Video from '@/components/icons/Video.vue';
import { useUIState } from '@/stores/uiState';
import { useRouter } from 'vue-router';
import Referral from './Referral.vue';
interface Props {
loading: boolean;
}
@@ -14,33 +17,34 @@ defineProps<Props>();
const uiState = useUIState();
const router = useRouter();
const { t } = useI18n();
const quickActions = [
const quickActions = computed(() => [
{
title: 'Upload Video',
description: 'Upload a new video to your library',
title: t('overview.quickActions.uploadVideo.title'),
description: t('overview.quickActions.uploadVideo.description'),
icon: Upload,
onClick: () => uiState.toggleUploadDialog()
},
{
title: 'Video Library',
description: 'Browse all your videos',
title: t('overview.quickActions.videoLibrary.title'),
description: t('overview.quickActions.videoLibrary.description'),
icon: Video,
onClick: () => router.push('/video')
},
{
title: 'Analytics',
description: 'Track performance & insights',
title: t('overview.quickActions.analytics.title'),
description: t('overview.quickActions.analytics.description'),
icon: Chart,
onClick: () => { }
},
{
title: 'Manage Plan',
description: 'Upgrade or change your plan',
title: t('overview.quickActions.managePlan.title'),
description: t('overview.quickActions.managePlan.description'),
icon: Credit,
onClick: () => router.push('/payments-and-plans')
},
];
]);
</script>
<template>
@@ -63,7 +67,7 @@ const quickActions = [
</div>
<div v-else class="mb-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<h2 class="text-xl font-semibold mb-4">{{ t('overview.quickActions.title') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<button v-for="action in quickActions" :key="action.title" @click="action.onClick" :class="[

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { ModelVideo } from '@/api/client';
import EmptyState from '@/components/dashboard/EmptyState.vue';
import { formatBytes, formatDate, formatDuration } from '@/lib/utils';
import { formatDate, formatDuration } from '@/lib/utils';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
interface Props {
@@ -12,6 +13,7 @@ interface Props {
defineProps<Props>();
const router = useRouter();
const { t } = useI18n();
const getStatusClass = (status?: string) => {
switch (status?.toLowerCase()) {
@@ -45,17 +47,17 @@ const getStatusClass = (status?: string) => {
<div v-else>
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Recent Videos</h2>
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
<router-link to="/video"
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
View all
{{ t('overview.recentVideos.viewAll') }}
<span class="i-heroicons-arrow-right w-4 h-4" />
</router-link>
</div>
<EmptyState v-if="videos.length === 0" title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
:description="t('overview.recentVideos.emptyDescription')"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
:onAction="() => router.push('/upload')" />
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
@@ -65,19 +67,19 @@ const getStatusClass = (status?: string) => {
<tr>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Video</th>
{{ t('overview.recentVideos.table.video') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status</th>
{{ t('overview.recentVideos.table.status') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration</th>
{{ t('overview.recentVideos.table.duration') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Upload Date</th>
{{ t('overview.recentVideos.table.uploadDate') }}</th>
<th
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions</th>
{{ t('overview.recentVideos.table.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@@ -94,14 +96,14 @@ const getStatusClass = (status?: string) => {
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
<p class="text-sm text-gray-500 truncate">
{{ video.description || 'No description' }}</p>
{{ video.description || t('overview.recentVideos.noDescription') }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
{{ video.status || 'Unknown' }}
{{ video.status || t('overview.recentVideos.unknownStatus') }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
@@ -112,13 +114,13 @@ const getStatusClass = (status?: string) => {
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionEdit')">
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionShare')">
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete">
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" :title="t('overview.recentVideos.actionDelete')">
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button>
</div>

View File

@@ -1,14 +1,13 @@
<template>
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-surface">
<div class="flex flex-col space-y-1.5 p-6">
<h3 class="text-lg font-semibold leading-none tracking-tight">Referral Link</h3>
<h3 class="text-lg font-semibold leading-none tracking-tight">{{ t('overview.referral.title') }}</h3>
</div>
<div class="p-6 pt-0 space-y-4">
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
referred users!</p>
<p class="text-sm text-gray-600 font-medium">{{ t('overview.referral.subtitle') }}</p>
<div class="flex gap-2">
<AppInput class="w-full" readonly type="text" :modelValue="url" @click="copyToClipboard" />
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied">
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied" :aria-label="t('common.copy')">
<svg v-if="!isCopied" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="lucide lucide-copy" aria-hidden="true">
@@ -28,19 +27,25 @@
</template>
<script lang="ts" setup>
import { useAuthStore } from '@/stores/auth';
import { ref } from 'vue';
const auth = useAuthStore()
const isCopied = ref(false)
const url = location.origin + '/ref/' + auth.user?.username
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const auth = useAuthStore();
const isCopied = ref(false);
const { t } = useI18n();
const url = computed(() => `${location.origin}/ref/${auth.user?.username || ''}`);
const copyToClipboard = ($event: MouseEvent) => {
// ($event.target as HTMLInputElement)?.select
if ($event.target instanceof HTMLInputElement) {
$event.target.select()
$event.target.select();
}
navigator.clipboard.writeText(url)
isCopied.value = true
navigator.clipboard.writeText(url.value);
isCopied.value = true;
setTimeout(() => {
isCopied.value = false
}, 3000)
}
isCopied.value = false;
}, 3000);
};
</script>

View File

@@ -1,4 +1,5 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils';
@@ -14,6 +15,7 @@ interface Props {
}
defineProps<Props>();
const { t } = useI18n();
</script>
<template>
@@ -30,15 +32,15 @@ defineProps<Props>();
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard title="Total Videos" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()"
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString()"
:trend="{ value: 8, isPositive: true }" />
<StatsCard title="Storage Used"
<StatsCard :title="t('overview.stats.storageUsed')"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
<StatsCard :title="t('overview.stats.uploadsThisMonth')" :value="stats.uploadsThisMonth" color="success"
:trend="{ value: 25, isPositive: true }" />
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { formatBytes } from '@/lib/utils';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
interface Props {
loading: boolean;
@@ -12,6 +13,7 @@ interface Props {
}
const props = defineProps<Props>();
const { t } = useI18n();
const storagePercentage = computed(() => {
return Math.round((props.stats.storageUsed / props.stats.storageLimit) * 100);
@@ -24,21 +26,21 @@ const storageBreakdown = computed(() => {
const total = videoSize + thumbSize + otherSize;
return [
{ label: 'Videos', size: videoSize, percentage: (videoSize / (total || 1)) * 100, color: 'bg-primary' },
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / (total || 1)) * 100, color: 'bg-blue-500' },
{ label: 'Other Files', size: otherSize, percentage: (otherSize / (total || 1)) * 100, color: 'bg-gray-400' },
{ label: t('overview.storage.breakdown.videos'), size: videoSize, percentage: (videoSize / (total || 1)) * 100, color: 'bg-primary' },
{ label: t('overview.storage.breakdown.thumbnails'), size: thumbSize, percentage: (thumbSize / (total || 1)) * 100, color: 'bg-blue-500' },
{ label: t('overview.storage.breakdown.other'), size: otherSize, percentage: (otherSize / (total || 1)) * 100, color: 'bg-gray-400' },
];
});
</script>
<template>
<div v-if="!loading" class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
<h2 class="text-xl font-semibold mb-4">{{ t('overview.storage.title') }}</h2>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
{{ t('overview.storage.usedOfLimit', { used: formatBytes(stats.storageUsed), limit: formatBytes(stats.storageLimit) }) }}
</span>
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
{{ storagePercentage }}%
@@ -66,10 +68,10 @@ const storageBreakdown = computed(() => {
<div class="flex gap-2">
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
<p class="text-sm font-medium text-yellow-800">{{ t('overview.storage.lowStorage.title') }}</p>
<p class="text-sm text-yellow-700 mt-1">
Consider upgrading your plan to get more storage.
<router-link to="/plans" class="underline font-medium">View plans</router-link>
{{ t('overview.storage.lowStorage.message') }}
<router-link to="/plans" class="underline font-medium">{{ t('overview.storage.lowStorage.viewPlans') }}</router-link>
</p>
</div>
</div>

View File

@@ -1,16 +1,16 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { useI18n } from 'vue-i18n';
const auth = useAuthStore()
const auth = useAuthStore();
const { t } = useI18n();
</script>
<template>
<div class="bg-gradient-to-r to-success/20 p-4 sm:p-6 md:p-8 rounded-xl border-2 border-success/30 mb-8">
<h1 class="text-2xl sm:text-3xl md:text-4xl font-extrabold text-foreground mb-2">Welcome back, {{
auth.user?.username }}! 👋
<h1 class="text-2xl sm:text-3xl md:text-4xl font-extrabold text-foreground mb-2">
{{ t('overview.welcome.title', { name: auth.user?.username || t('app.name') }) }}
</h1>
<p class="text-sm sm:text-base text-gray-600 font-medium">Here's what's happening with your content
today.</p>
<p class="text-sm sm:text-base text-gray-600 font-medium">{{ t('overview.welcome.subtitle') }}</p>
</div>
</template>

View File

@@ -1,8 +1,8 @@
<template>
<section>
<PageHeader
:title="content[route.name as keyof typeof content]?.title || 'Settings'"
:description="content[route.name as keyof typeof content]?.subtitle || 'Manage your account settings and preferences.'"
:title="content[route.name as keyof typeof content]?.title || t('settings.content.fallbackTitle')"
:description="content[route.name as keyof typeof content]?.subtitle || t('settings.content.fallbackSubtitle')"
:breadcrumbs="breadcrumbs"
/>
<div class="max-w-7xl mx-auto pb-12">
@@ -15,7 +15,7 @@
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('app.name') }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
@@ -63,6 +63,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import AppConfirmHost from '@/components/app/AppConfirmHost.vue';
@@ -79,6 +80,7 @@ import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
const route = useRoute();
const auth = useAuthStore();
const { t } = useI18n();
// Map tab values to their paths
const tabPaths: Record<string, string> = {
profile: '/settings',
@@ -92,37 +94,37 @@ const tabPaths: Record<string, string> = {
};
// Menu items grouped by category (GitHub-style)
const menuSections: { title?: string; items: { value: string; label: string; icon: any; danger?: boolean }[] }[] = [
const menuSections = computed(() => [
{
title: 'Security',
title: t('settings.menu.securityGroup'),
items: [
{ value: 'security', label: 'Security', icon: UserIcon },
{ value: 'billing', label: 'Billing & Plans', icon: CreditCardIcon },
{ value: 'security', label: t('settings.menu.security'), icon: UserIcon },
{ value: 'billing', label: t('settings.menu.billing'), icon: CreditCardIcon },
],
},
{
title: 'Preferences',
title: t('settings.menu.preferencesGroup'),
items: [
{ value: 'notifications', label: 'Notifications', icon: Bell },
{ value: 'player', label: 'Player', icon: VideoPlayIcon },
{ value: 'notifications', label: t('settings.menu.notifications'), icon: Bell },
{ value: 'player', label: t('settings.menu.player'), icon: VideoPlayIcon },
],
},
{
title: 'Integrations',
title: t('settings.menu.integrationsGroup'),
items: [
{ value: 'domains', label: 'Allowed Domains', icon: GlobeIcon },
{ value: 'ads', label: 'Ads & VAST', icon: AdvertisementIcon },
{ value: 'domains', label: t('settings.menu.domains'), icon: GlobeIcon },
{ value: 'ads', label: t('settings.menu.ads'), icon: AdvertisementIcon },
],
},
{
title: 'Danger Zone',
title: t('settings.menu.dangerGroup'),
items: [
{ value: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
{ value: 'danger', label: t('settings.menu.danger'), icon: AlertTriangle, danger: true },
],
},
] as const;
] as const);
type TabValue = typeof menuSections[number]['items'][number]['value'];
type TabValue = 'profile' | 'security' | 'notifications' | 'player' | 'billing' | 'domains' | 'ads' | 'danger';
// Get current tab from route path
const currentTab = computed<TabValue>(() => {
@@ -133,43 +135,43 @@ const currentTab = computed<TabValue>(() => {
});
// Breadcrumbs with dynamic tab
const allMenuItems = menuSections.flatMap(section => section.items);
const currentItem = allMenuItems.find(item => item.value === currentTab.value);
const allMenuItems = computed(() => menuSections.value.flatMap(section => section.items));
const currentItem = computed(() => allMenuItems.value.find(item => item.value === currentTab.value));
const breadcrumbs = [
{ label: 'Dashboard', to: '/overview' },
{ label: 'Settings', to: '/settings' },
...(currentItem ? [{ label: currentItem.label }] : []),
];
const breadcrumbs = computed(() => [
{ label: t('pageHeader.dashboard'), to: '/overview' },
{ label: t('pageHeader.settings'), to: '/settings' },
...(currentItem.value ? [{ label: currentItem.value.label }] : []),
]);
const content = {
security: {
title: 'Security & Connected Apps',
subtitle: 'Manage your security settings and connected applications.'
const content = computed(() => ({
'settings-security': {
title: t('settings.content.security.title'),
subtitle: t('settings.content.security.subtitle')
},
notifications: {
title: 'Notifications',
subtitle: 'Choose how you want to receive notifications and updates.'
'settings-notifications': {
title: t('settings.content.notifications.title'),
subtitle: t('settings.content.notifications.subtitle')
},
player: {
title: 'Player Settings',
subtitle: 'Configure default video player behavior and features.'
'settings-player': {
title: t('settings.content.player.title'),
subtitle: t('settings.content.player.subtitle')
},
billing: {
title: 'Billing & Plans',
subtitle: 'Your current subscription and billing information.'
'settings-billing': {
title: t('settings.content.billing.title'),
subtitle: t('settings.content.billing.subtitle')
},
domains: {
title: 'Allowed Domains',
subtitle: 'Add domains to your whitelist to allow embedding content via iframe.'
'settings-domains': {
title: t('settings.content.domains.title'),
subtitle: t('settings.content.domains.subtitle')
},
ads: {
title: 'Ads & VAST',
subtitle: 'Create and manage VAST ad templates for your videos.'
'settings-ads': {
title: t('settings.content.ads.title'),
subtitle: t('settings.content.ads.subtitle')
},
danger: {
title: 'Danger Zone',
subtitle: 'Irreversible and destructive actions. Be careful!'
'settings-danger': {
title: t('settings.content.danger.title'),
subtitle: t('settings.content.danger.subtitle')
}
}
}));
</script>

View File

@@ -6,6 +6,7 @@ import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
dialogVisible: boolean;
@@ -30,6 +31,8 @@ const emit = defineEmits<{
(e: 'disconnect-telegram'): void;
}>();
const { t } = useI18n();
const handleChangePassword = () => {
emit('change-password');
};
@@ -37,14 +40,11 @@ const handleChangePassword = () => {
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground mb-3">Connected Accounts</h3>
<h3 class="text-sm font-semibold text-foreground mb-3">{{ t('settings.connectedAccounts.title') }}</h3>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Email Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-info/10 flex items-center justify-center shrink-0">
@@ -54,27 +54,26 @@ const handleChangePassword = () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.connectedAccounts.email.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
{{ emailConnected ? t('settings.connectedAccounts.email.connected') : t('settings.connectedAccounts.email.notConnected') }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
{{ emailConnected ? t('settings.connectedAccounts.email.connected') : t('settings.connectedAccounts.email.disconnected') }}
</span>
</div>
<!-- Telegram Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.connectedAccounts.telegram.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
{{ telegramConnected ? (telegramUsername || t('settings.connectedAccounts.telegram.connectedFallback')) : t('settings.connectedAccounts.telegram.hint') }}
</p>
</div>
</div>
@@ -84,43 +83,40 @@ const handleChangePassword = () => {
size="sm"
@click="$emit('disconnect-telegram')"
>
Disconnect
{{ t('common.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="$emit('connect-telegram')"
>
Connect
{{ t('common.connect') }}
</AppButton>
</div>
</div>
<!-- Change Password Dialog -->
<AppDialog
:visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $event)"
title="Change Password"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<!-- Error Message -->
<div v-if="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</div>
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
@update:model-value="$emit('update:currentPassword', $event)"
>
<template #prefix>
@@ -129,14 +125,13 @@ const handleChangePassword = () => {
</AppInput>
</div>
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
@update:model-value="$emit('update:newPassword', $event)"
>
<template #prefix>
@@ -145,14 +140,13 @@ const handleChangePassword = () => {
</AppInput>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
@update:model-value="$emit('update:confirmPassword', $event)"
>
<template #prefix>
@@ -172,7 +166,7 @@ const handleChangePassword = () => {
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
{{ t('common.cancel') }}
</AppButton>
<AppButton
size="sm"
@@ -182,7 +176,7 @@ const handleChangePassword = () => {
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import AppButton from '@/components/app/AppButton.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppProgressBar from '@/components/app/AppProgressBar.vue';
@@ -11,6 +12,7 @@ import UserIcon from '@/components/icons/UserIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const auth = useAuthStore();
const { t } = useI18n();
const props = defineProps<{
editing: boolean;
@@ -46,31 +48,27 @@ const formatBytes = (bytes: number) => {
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Profile Information</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.profile.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your personal information and account details.
{{ t('settings.profile.subtitle') }}
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-6">
<!-- User Avatar & Name -->
<div class="flex items-center gap-4 pb-4 border-b border-border">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || t('settings.profile.userFallback') }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<!-- Form Fields -->
<div class="grid gap-6 max-w-2xl">
<div class="grid gap-2">
<label for="username" class="text-sm font-medium text-foreground">Username</label>
<label for="username" class="text-sm font-medium text-foreground">{{ t('settings.profile.username') }}</label>
<AppInput
id="username"
:model-value="username"
@@ -84,7 +82,7 @@ const formatBytes = (bytes: number) => {
</AppInput>
</div>
<div class="grid gap-2">
<label for="email" class="text-sm font-medium text-foreground">Email Address</label>
<label for="email" class="text-sm font-medium text-foreground">{{ t('settings.profile.email') }}</label>
<AppInput
id="email"
:model-value="email"
@@ -99,7 +97,6 @@ const formatBytes = (bytes: number) => {
</div>
</div>
<!-- Storage Usage -->
<div class="pt-4 border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
@@ -110,8 +107,8 @@ const formatBytes = (bytes: number) => {
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">Storage Usage</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.profile.storageUsage') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.profile.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) }) }}</p>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
@@ -119,20 +116,19 @@ const formatBytes = (bytes: number) => {
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
<template v-if="editing">
<AppButton size="sm" :loading="saving" @click="emit('save')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
{{ t('common.save') }}
</AppButton>
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('cancel-edit')">
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
{{ t('common.cancel') }}
</AppButton>
</template>
<template v-else>
@@ -140,10 +136,10 @@ const formatBytes = (bytes: number) => {
<template #icon>
<PencilIcon class="w-4 h-4" />
</template>
Edit Profile
{{ t('settings.profile.editProfile') }}
</AppButton>
<AppButton variant="secondary" size="sm" @click="emit('change-password')">
Change Password
{{ t('settings.profile.changePassword') }}
</AppButton>
</template>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
@@ -32,6 +33,7 @@ const emit = defineEmits<{
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const { t } = useI18n();
const handleToggle2FA = async () => {
if (!props.twoFactorEnabled) {
@@ -46,23 +48,18 @@ const confirmTwoFactor = async () => {
twoFactorDialogVisible.value = false;
twoFactorCode.value = '';
};
// (kept minimal; no dynamic items list needed)
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.securityConnected.header.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
{{ t('settings.securityConnected.header.subtitle') }}
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Account Status -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center shrink-0">
@@ -72,23 +69,22 @@ const confirmTwoFactor = async () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.accountStatus.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.accountStatus.detail') }}</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactor.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
{{ twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled') }}
</p>
</div>
</div>
@@ -98,40 +94,38 @@ const confirmTwoFactor = async () => {
@change="handleToggle2FA"
/>
</div>
<!-- Change Password -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Update your account password
{{ t('settings.securityConnected.changePassword.detail') }}
</p>
</div>
</div>
<AppButton size="sm" @click="$emit('change-password')">
Change Password
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</div>
</div>
<!-- 2FA Setup Dialog -->
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
title="Enable Two-Factor Authentication"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
@@ -143,19 +137,17 @@ const confirmTwoFactor = async () => {
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
@@ -166,13 +158,13 @@ const confirmTwoFactor = async () => {
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Verify & Enable
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>

View File

@@ -11,12 +11,13 @@ import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useI18n();
// VAST Templates
interface VastTemplate {
id: string;
name: string;
@@ -47,6 +48,8 @@ const templates = ref<VastTemplate[]>([
},
]);
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
@@ -85,30 +88,55 @@ const openEditDialog = (template: VastTemplate) => {
const handleSave = () => {
if (!formData.value.name.trim()) {
toast.add({ severity: 'error', summary: 'Name Required', detail: 'Please enter a template name.', life: 3000 });
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.nameRequiredSummary'),
detail: t('settings.adsVast.toast.nameRequiredDetail'),
life: 3000,
});
return;
}
if (!formData.value.vastUrl.trim()) {
toast.add({ severity: 'error', summary: 'VAST URL Required', detail: 'Please enter the VAST tag URL.', life: 3000 });
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.urlRequiredSummary'),
detail: t('settings.adsVast.toast.urlRequiredDetail'),
life: 3000,
});
return;
}
try {
new URL(formData.value.vastUrl);
} catch {
toast.add({ severity: 'error', summary: 'Invalid URL', detail: 'Please enter a valid URL.', life: 3000 });
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.invalidUrlSummary'),
detail: t('settings.adsVast.toast.invalidUrlDetail'),
life: 3000,
});
return;
}
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
toast.add({ severity: 'error', summary: 'Duration Required', detail: 'Mid-roll ads require a duration/interval.', life: 3000 });
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.durationRequiredSummary'),
detail: t('settings.adsVast.toast.durationRequiredDetail'),
life: 3000,
});
return;
}
if (editingTemplate.value) {
const index = templates.value.findIndex(t => t.id === editingTemplate.value!.id);
const index = templates.value.findIndex(template => template.id === editingTemplate.value!.id);
if (index !== -1) {
templates.value[index] = { ...templates.value[index], ...formData.value };
}
toast.add({ severity: 'success', summary: 'Template Updated', detail: 'VAST template has been updated.', life: 3000 });
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.updatedSummary'),
detail: t('settings.adsVast.toast.updatedDetail'),
life: 3000,
});
} else {
templates.value.push({
id: Math.random().toString(36).substring(2, 9),
@@ -116,7 +144,12 @@ const handleSave = () => {
enabled: true,
createdAt: new Date().toISOString().split('T')[0],
});
toast.add({ severity: 'success', summary: 'Template Created', detail: 'VAST template has been created.', life: 3000 });
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.createdSummary'),
detail: t('settings.adsVast.toast.createdDetail'),
life: 3000,
});
}
showAddDialog.value = false;
@@ -127,39 +160,55 @@ const handleToggle = (template: VastTemplate) => {
template.enabled = !template.enabled;
toast.add({
severity: 'info',
summary: template.enabled ? 'Template Enabled' : 'Template Disabled',
detail: `${template.name} has been ${template.enabled ? 'enabled' : 'disabled'}.`,
life: 2000
summary: template.enabled
? t('settings.adsVast.toast.enabledSummary')
: t('settings.adsVast.toast.disabledSummary'),
detail: t('settings.adsVast.toast.toggleDetail', {
name: template.name,
state: template.enabled
? t('settings.adsVast.state.enabled')
: t('settings.adsVast.state.disabled'),
}),
life: 2000,
});
};
const handleDelete = (template: VastTemplate) => {
confirm.require({
message: `Are you sure you want to delete "${template.name}"?`,
header: 'Delete Template',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
header: t('settings.adsVast.confirm.deleteHeader'),
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
accept: () => {
const index = templates.value.findIndex(t => t.id === template.id);
const index = templates.value.findIndex(item => item.id === template.id);
if (index !== -1) templates.value.splice(index, 1);
toast.add({ severity: 'info', summary: 'Template Deleted', detail: 'VAST template has been removed.', life: 3000 });
}
toast.add({
severity: 'info',
summary: t('settings.adsVast.toast.deletedSummary'),
detail: t('settings.adsVast.toast.deletedDetail'),
life: 3000,
});
},
});
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: 'URL copied to clipboard.', life: 2000 });
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.copiedSummary'),
detail: t('settings.adsVast.toast.copiedDetail'),
life: 2000,
});
};
const getAdFormatLabel = (format: string) => {
const labels: Record<string, string> = {
'pre-roll': 'Pre-roll',
'mid-roll': 'Mid-roll',
'post-roll': 'Post-roll',
};
return labels[format] || format;
};
const adFormatLabels = computed(() => ({
'pre-roll': t('settings.adsVast.formats.preRoll'),
'mid-roll': t('settings.adsVast.formats.midRoll'),
'post-roll': t('settings.adsVast.formats.postRoll'),
}));
const getAdFormatLabel = (format: string) => adFormatLabels.value[format as keyof typeof adFormatLabels.value] || format;
const getAdFormatColor = (format: string) => {
const colors: Record<string, string> = {
@@ -173,42 +222,39 @@ const getAdFormatColor = (format: string) => {
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Ads & VAST</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.ads.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Create and manage VAST ad templates for your videos.
{{ t('settings.content.ads.subtitle') }}
</p>
</div>
<AppButton size="sm" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
Create Template
{{ t('settings.adsVast.createTemplate') }}
</AppButton>
</div>
<!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-info mt-0.5" />
<div class="text-xs text-foreground/70">
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
{{ t('settings.adsVast.infoBanner') }}
</div>
</div>
</div>
<!-- Templates Table -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Template</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Format</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">VAST URL</th>
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Status</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.template') }}</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.format') }}</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.vastUrl') }}</th>
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.status') }}</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@@ -220,7 +266,7 @@ const getAdFormatColor = (format: string) => {
<td class="px-6 py-3">
<div>
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
<p class="text-xs text-foreground/50 mt-0.5">Created {{ template.createdAt }}</p>
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt }) }}</p>
</div>
</td>
<td class="px-6 py-3">
@@ -265,65 +311,64 @@ const getAdFormatColor = (format: string) => {
<tr v-if="templates.length === 0">
<td colspan="5" class="px-6 py-12 text-center">
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
<p class="text-sm text-foreground/60 mb-1">No VAST templates yet</p>
<p class="text-xs text-foreground/40">Create a template to start monetizing your videos</p>
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.adsVast.emptyTitle') }}</p>
<p class="text-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add/Edit Dialog -->
<AppDialog
:visible="showAddDialog"
@update:visible="showAddDialog = $event"
:title="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
maxWidthClass="max-w-lg"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">Template Name</label>
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
<AppInput
id="name"
v-model="formData.name"
placeholder="e.g., Main Pre-roll Ad"
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
/>
</div>
<div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label>
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
<AppInput
id="vastUrl"
v-model="formData.vastUrl"
placeholder="https://ads.example.com/vast/tag.xml"
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">Ad Format</label>
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in ['pre-roll', 'mid-roll', 'post-roll']"
v-for="format in adFormatOptions"
:key="format"
@click="formData.adFormat = format as any"
@click="formData.adFormat = format"
:class="[
'px-3 py-2 border rounded-md text-sm font-medium capitalize transition-all',
'px-3 py-2 border rounded-md text-sm font-medium transition-all',
formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50'
]">
{{ format }}
{{ getAdFormatLabel(format) }}
</button>
</div>
</div>
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
<label for="duration" class="text-sm font-medium text-foreground">Ad Interval (seconds)</label>
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
<AppInput
id="duration"
v-model.number="formData.duration"
type="number"
placeholder="30"
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
:min="10"
:max="600"
/>
@@ -333,13 +378,13 @@ const getAdFormatColor = (format: string) => {
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
Cancel
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" @click="handleSave">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ editingTemplate ? 'Update' : 'Create' }}
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
</AppButton>
</div>
</template>

View File

@@ -1,37 +1,36 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const toast = useAppToast();
const auth = useAuthStore();
const { t, locale } = useI18n();
const { data, isPending, isLoading } = useQuery({
const { data, isLoading } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(null);
// Top-up state
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(0);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
// Mock Payment History Data
const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
@@ -39,13 +38,11 @@ const paymentHistory = ref([
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
// Computed Usage (from user data)
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
// Wallet balance (from user data or mock)
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const currentPlanId = computed(() => {
@@ -54,12 +51,6 @@ const currentPlanId = computed(() => {
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
// Percentages
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
@@ -76,8 +67,8 @@ const formatBytes = (bytes: number) => {
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} mins`;
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
};
const getStatusStyles = (status: string) => {
@@ -93,7 +84,22 @@ const getStatusStyles = (status: string) => {
}
};
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
success: t('settings.billing.status.success'),
failed: t('settings.billing.status.failed'),
pending: t('settings.billing.status.pending'),
};
return map[status] || status;
};
const currencyFormatter = computed(() => new Intl.NumberFormat(locale.value === 'vi' ? 'vi-VN' : 'en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}));
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
@@ -101,30 +107,30 @@ const subscribe = async (plan: ModelPlan) => {
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id
plan_id: plan.id,
});
toast.add({
severity: 'success',
summary: 'Subscription Successful',
detail: `Successfully subscribed to ${plan.name}`,
life: 3000
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
detail: t('settings.billing.toast.subscriptionSuccessDetail', { plan: plan.name || '' }),
life: 3000,
});
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
date: new Date().toLocaleDateString(locale.value === 'vi' ? 'vi-VN' : 'en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || 'Unknown',
plan: plan.name || t('settings.billing.unknownPlan'),
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
});
} catch (err: any) {
console.error(err);
toast.add({
severity: 'error',
summary: 'Subscription Failed',
detail: err.message || 'Failed to subscribe',
life: 5000
summary: t('settings.billing.toast.subscriptionFailedSummary'),
detail: err.message || t('settings.billing.toast.subscriptionFailedDetail'),
life: 5000,
});
} finally {
subscribing.value = null;
@@ -134,23 +140,22 @@ const subscribe = async (plan: ModelPlan) => {
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
// TODO: Add API endpoint for top-up
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: 'Top-up Successful',
detail: `$${amount} has been added to your wallet.`,
life: 3000
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Top-up Failed',
detail: e.message || 'Failed to process top-up.',
life: 5000
summary: t('settings.billing.toast.topupFailedSummary'),
detail: e.message || t('settings.billing.toast.topupFailedDetail'),
life: 5000,
});
} finally {
topupLoading.value = false;
@@ -160,17 +165,17 @@ const handleTopup = async (amount: number) => {
const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
summary: t('settings.billing.toast.downloadingSummary'),
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
life: 2000,
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
summary: t('settings.billing.toast.downloadedSummary'),
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
life: 3000,
});
}, 1500);
};
@@ -187,26 +192,23 @@ const selectPreset = (amount: number) => {
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Billing & Plans</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.billing.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your subscription, wallet, and billing information.
{{ t('settings.content.billing.subtitle') }}
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Wallet Balance -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CoinsIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Wallet Balance</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.walletBalance') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Current balance: ${{ walletBalance.toFixed(2) }}
{{ t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) }) }}
</p>
</div>
</div>
@@ -214,24 +216,23 @@ const selectPreset = (amount: number) => {
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
Top Up
{{ t('settings.billing.topUp') }}
</AppButton>
</div>
<!-- Available Plans -->
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Available Plans</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.availablePlans') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Choose the plan that best fits your needs
{{ t('settings.billing.availablePlansHint') }}
</p>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
@@ -250,22 +251,22 @@ const selectPreset = (amount: number) => {
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">${{ plan.price }}</span>
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatBytes(plan.storage_limit || 0) }} Storage
{{ t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) }) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatDuration(plan.duration_limit) }} Max Duration
{{ t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) }) }}
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ plan.upload_limit }} Uploads / day
{{ t('settings.billing.planUploads', { count: plan.upload_limit }) }}
</li>
</ul>
@@ -281,21 +282,23 @@ const selectPreset = (amount: number) => {
]"
@click="subscribe(plan)"
>
{{ plan.id === currentPlanId ? 'Current Plan' : (subscribing === plan.id ? 'Processing...' : 'Upgrade') }}
{{ plan.id === currentPlanId
? t('settings.billing.currentPlan')
: (subscribing === plan.id ? t('settings.billing.processing') : t('settings.billing.upgrade')) }}
</button>
</div>
</div>
</div>
<!-- Storage Usage -->
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-5 h-5 text-accent" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Storage</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.storage') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
{{ t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) }) }}
</p>
</div>
</div>
@@ -307,16 +310,15 @@ const selectPreset = (amount: number) => {
</div>
</div>
<!-- Uploads Usage -->
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Monthly Uploads</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.monthlyUploads') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
{{ t('settings.billing.uploadsUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit }) }}
</p>
</div>
</div>
@@ -328,39 +330,35 @@ const selectPreset = (amount: number) => {
</div>
</div>
<!-- Payment History -->
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Payment History</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentHistory') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Your past payments and invoices
{{ t('settings.billing.paymentHistorySubtitle') }}
</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">Date</div>
<div class="col-span-2">Amount</div>
<div class="col-span-3">Plan</div>
<div class="col-span-2">Status</div>
<div class="col-span-2 text-right">Invoice</div>
<div class="col-span-3">{{ t('settings.billing.table.date') }}</div>
<div class="col-span-2">{{ t('settings.billing.table.amount') }}</div>
<div class="col-span-3">{{ t('settings.billing.table.plan') }}</div>
<div class="col-span-2">{{ t('settings.billing.table.status') }}</div>
<div class="col-span-2 text-right">{{ t('settings.billing.table.invoice') }}</div>
</div>
<!-- Empty State -->
<div v-if="paymentHistory.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>No payment history found.</p>
<p>{{ t('settings.billing.noPaymentHistory') }}</p>
</div>
<!-- Table Rows -->
<div
v-for="item in paymentHistory"
:key="item.id"
@@ -370,7 +368,7 @@ const selectPreset = (amount: number) => {
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">${{ item.amount }}</p>
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
@@ -379,7 +377,7 @@ const selectPreset = (amount: number) => {
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ capitalize(item.status) }}
{{ getStatusLabel(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
@@ -388,7 +386,7 @@ const selectPreset = (amount: number) => {
@click="handleDownloadInvoice(item)"
>
<DownloadIcon class="w-4 h-4" />
<span>Download</span>
<span>{{ t('settings.billing.download') }}</span>
</button>
</div>
</div>
@@ -396,19 +394,17 @@ const selectPreset = (amount: number) => {
</div>
</div>
<!-- Top-up Dialog -->
<AppDialog
:visible="topupDialogVisible"
@update:visible="topupDialogVisible = $event"
title="Top Up Wallet"
:title="t('settings.billing.topupDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Select an amount or enter a custom amount to add to your wallet.
{{ t('settings.billing.topupDialog.subtitle') }}
</p>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
@@ -421,19 +417,18 @@ const selectPreset = (amount: number) => {
]"
@click="selectPreset(preset)"
>
${{ preset }}
{{ formatMoney(preset) }}
</button>
</div>
<!-- Custom Amount -->
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">Custom Amount</label>
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.topupDialog.customAmount') }}</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<AppInput
v-model.number="topupAmount"
type="number"
placeholder="Enter amount"
:placeholder="t('settings.billing.topupDialog.enterAmount')"
inputClass="flex-1"
min="1"
step="1"
@@ -441,9 +436,8 @@ const selectPreset = (amount: number) => {
</div>
</div>
<!-- Info -->
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>Minimum top-up amount is $1. Funds will be added to your wallet immediately after payment.</p>
<p>{{ t('settings.billing.topupDialog.hint') }}</p>
</div>
</div>
<template #footer>
@@ -454,7 +448,7 @@ const selectPreset = (amount: number) => {
:disabled="topupLoading"
@click="topupDialogVisible = false"
>
Cancel
{{ t('common.cancel') }}
</AppButton>
<AppButton
size="sm"
@@ -465,7 +459,7 @@ const selectPreset = (amount: number) => {
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Proceed to Payment
{{ t('settings.billing.topupDialog.proceed') }}
</AppButton>
</div>
</template>

View File

@@ -1,72 +1,71 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import AppButton from '@/components/app/AppButton.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { useI18n } from 'vue-i18n';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useI18n();
const handleDeleteAccount = () => {
confirm.require({
message: 'Are you sure you want to delete your account? This action cannot be undone.',
header: 'Delete Account',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
accept: () => {
toast.add({
severity: 'info',
summary: 'Account deletion requested',
detail: 'Your account deletion request has been submitted.',
life: 5000
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
life: 5000,
});
}
},
});
};
const handleClearData = () => {
confirm.require({
message: 'Are you sure you want to clear all your data? This action cannot be undone.',
header: 'Clear All Data',
acceptLabel: 'Clear',
rejectLabel: 'Cancel',
message: t('settings.dangerZone.confirm.clearDataMessage'),
header: t('settings.dangerZone.confirm.clearDataHeader'),
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
accept: () => {
toast.add({
severity: 'info',
summary: 'Data cleared',
detail: 'All your data has been permanently deleted.',
life: 5000
summary: t('settings.dangerZone.toast.clearDataSummary'),
detail: t('settings.dangerZone.toast.clearDataDetail'),
life: 5000,
});
}
},
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-danger">Danger Zone</h2>
<h2 class="text-base font-semibold text-danger">{{ t('settings.content.danger.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Irreversible and destructive actions. Be careful!
{{ t('settings.content.danger.subtitle') }}
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Delete Account -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Delete Account</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.dangerZone.deleteAccount.title') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Permanently delete your account and all associated data.
{{ t('settings.dangerZone.deleteAccount.description') }}
</p>
</div>
</div>
@@ -74,11 +73,10 @@ const handleClearData = () => {
<template #icon>
<TrashIcon class="w-4 h-4" />
</template>
Delete Account
{{ t('settings.dangerZone.deleteAccount.button') }}
</AppButton>
</div>
<!-- Clear All Data -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
@@ -89,9 +87,9 @@ const handleClearData = () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Clear All Data</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.dangerZone.clearData.title') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
Remove all your videos, playlists, and activity history.
{{ t('settings.dangerZone.clearData.description') }}
</p>
</div>
</div>
@@ -99,20 +97,18 @@ const handleClearData = () => {
<template #icon>
<SlidersIcon class="w-4 h-4" />
</template>
Clear Data
{{ t('settings.dangerZone.clearData.button') }}
</AppButton>
</div>
</div>
<!-- Warning Banner -->
<div class="mx-6 my-4 border border-warning/30 bg-warning/5 rounded-md p-4">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-warning mt-0.5" />
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Warning</p>
<p class="font-medium text-foreground mb-1">{{ t('settings.dangerZone.warning.title') }}</p>
<p>
These actions are permanent and cannot be undone.
Make sure you have backed up any important data before proceeding.
{{ t('settings.dangerZone.warning.description') }}
</p>
</div>
</div>

View File

@@ -1,21 +1,22 @@
<script setup lang="ts">
import { ref } from 'vue';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import LinkIcon from '@/components/icons/LinkIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useI18n();
// Domain whitelist for iframe embedding
const domains = ref([
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
@@ -28,21 +29,20 @@ const handleAddDomain = () => {
if (!newDomain.value.trim()) {
toast.add({
severity: 'error',
summary: 'Invalid Domain',
detail: 'Please enter a valid domain name.',
life: 3000
summary: t('settings.domainsDns.toast.invalidSummary'),
detail: t('settings.domainsDns.toast.invalidDetail'),
life: 3000,
});
return;
}
// Check for duplicates
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
if (exists) {
toast.add({
severity: 'error',
summary: 'Domain Already Added',
detail: 'This domain is already in your whitelist.',
life: 3000
summary: t('settings.domainsDns.toast.duplicateSummary'),
detail: t('settings.domainsDns.toast.duplicateDetail'),
life: 3000,
});
return;
}
@@ -51,25 +51,25 @@ const handleAddDomain = () => {
domains.value.push({
id: Math.random().toString(36).substring(2, 9),
name: domainName,
addedAt: new Date().toISOString().split('T')[0]
addedAt: new Date().toISOString().split('T')[0],
});
newDomain.value = '';
showAddDialog.value = false;
toast.add({
severity: 'success',
summary: 'Domain Added',
detail: `${domainName} has been added to your whitelist.`,
life: 3000
summary: t('settings.domainsDns.toast.addedSummary'),
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
life: 3000,
});
};
const handleRemoveDomain = (domain: typeof domains.value[0]) => {
confirm.require({
message: `Are you sure you want to remove ${domain.name} from your whitelist? Embedded iframes from this domain will no longer work.`,
header: 'Remove Domain',
acceptLabel: 'Remove',
rejectLabel: 'Cancel',
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
header: t('settings.domainsDns.confirm.removeHeader'),
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
accept: () => {
const index = domains.value.findIndex(d => d.id === domain.id);
if (index !== -1) {
@@ -77,65 +77,60 @@ const handleRemoveDomain = (domain: typeof domains.value[0]) => {
}
toast.add({
severity: 'info',
summary: 'Domain Removed',
detail: `${domain.name} has been removed from your whitelist.`,
life: 3000
summary: t('settings.domainsDns.toast.removedSummary'),
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
life: 3000,
});
}
},
});
};
const getIframeCode = () => {
return `<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>`;
};
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
const copyIframeCode = () => {
navigator.clipboard.writeText(getIframeCode());
navigator.clipboard.writeText(iframeCode.value);
toast.add({
severity: 'success',
summary: 'Copied',
detail: 'Embed code copied to clipboard.',
life: 2000
summary: t('settings.domainsDns.toast.copiedSummary'),
detail: t('settings.domainsDns.toast.copiedDetail'),
life: 2000,
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Allowed Domains</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.domains.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Add domains to your whitelist to allow embedding content via iframe.
{{ t('settings.content.domains.subtitle') }}
</p>
</div>
<AppButton size="sm" @click="showAddDialog = true">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
Add Domain
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</div>
<!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-info mt-0.5" />
<div class="text-xs text-foreground/70">
Only domains in your whitelist can embed your content using iframe.
{{ t('settings.domainsDns.infoBanner') }}
</div>
</div>
</div>
<!-- Domain List -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Domain</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Added Date</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.domain') }}</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.addedDate') }}</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
@@ -162,56 +157,54 @@ const copyIframeCode = () => {
<tr v-if="domains.length === 0">
<td colspan="3" class="px-6 py-12 text-center">
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
<p class="text-sm text-foreground/60 mb-1">No domains in whitelist</p>
<p class="text-xs text-foreground/40">Add a domain to allow iframe embedding</p>
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Embed Code Section -->
<div class="px-6 py-4 bg-muted/30">
<div class="flex items-center justify-between mb-3">
<h4 class="text-sm font-medium text-foreground">Embed Code</h4>
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Copy Code
{{ t('settings.domainsDns.copyCode') }}
</AppButton>
</div>
<p class="text-xs text-foreground/60 mb-2">
Use this iframe code to embed content on your whitelisted domains.
{{ t('settings.domainsDns.embedCodeHint') }}
</p>
<pre class="bg-surface border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ getIframeCode() }}</code></pre>
<pre class="bg-surface border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
</div>
<!-- Add Domain Dialog -->
<AppDialog
:visible="showAddDialog"
@update:visible="showAddDialog = $event"
title="Add Domain to Whitelist"
:title="t('settings.domainsDns.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="domain" class="text-sm font-medium text-foreground">Domain Name</label>
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
<AppInput
id="domain"
v-model="newDomain"
placeholder="example.com"
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
@enter="handleAddDomain"
/>
<p class="text-xs text-foreground/50">Enter domain without www or https:// (e.g., example.com)</p>
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
</div>
<div class="bg-warning/5 border border-warning/20 rounded-md p-3">
<div class="flex items-start gap-2">
<AlertTriangleIcon class="w-4 h-4 text-warning mt-0.5" />
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Important</p>
<p>Only add domains that you own and control.</p>
<p class="font-medium text-foreground mb-1">{{ t('settings.domainsDns.dialog.importantTitle') }}</p>
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
</div>
</div>
</div>
@@ -219,13 +212,13 @@ const copyIframeCode = () => {
<template #footer>
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
Cancel
{{ t('common.cancel') }}
</AppButton>
<AppButton size="sm" @click="handleAddDomain">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Add Domain
{{ t('settings.domainsDns.addDomain') }}
</AppButton>
</template>
</AppDialog>

View File

@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import MailIcon from '@/components/icons/MailIcon.vue';
import BellIcon from '@/components/icons/BellIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import MailIcon from '@/components/icons/MailIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
const toast = useAppToast();
const { t } = useI18n();
const notificationSettings = ref({
email: true,
@@ -20,58 +22,57 @@ const notificationSettings = ref({
const saving = ref(false);
const notificationTypes = [
const notificationTypes = computed(() => [
{
key: 'email' as const,
title: 'Email Notifications',
description: 'Receive updates and alerts via email',
title: t('settings.notificationSettings.types.email.title'),
description: t('settings.notificationSettings.types.email.description'),
icon: MailIcon,
bgColor: 'bg-primary/10',
iconColor: 'text-primary',
},
{
key: 'push' as const,
title: 'Push Notifications',
description: 'Get instant alerts in your browser',
title: t('settings.notificationSettings.types.push.title'),
description: t('settings.notificationSettings.types.push.description'),
icon: BellIcon,
bgColor: 'bg-accent/10',
iconColor: 'text-accent',
},
{
key: 'marketing' as const,
title: 'Marketing Emails',
description: 'Receive promotions and product updates',
title: t('settings.notificationSettings.types.marketing.title'),
description: t('settings.notificationSettings.types.marketing.description'),
icon: SendIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
{
key: 'telegram' as const,
title: 'Telegram Notifications',
description: 'Receive updates via Telegram',
title: t('settings.notificationSettings.types.telegram.title'),
description: t('settings.notificationSettings.types.telegram.description'),
icon: TelegramIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
];
]);
const handleSave = async () => {
saving.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({
severity: 'success',
summary: 'Settings Saved',
detail: 'Your notification settings have been saved.',
life: 3000
summary: t('settings.notificationSettings.toast.savedSummary'),
detail: t('settings.notificationSettings.toast.savedDetail'),
life: 3000,
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Save Failed',
detail: e.message || 'Failed to save settings.',
life: 5000
summary: t('settings.notificationSettings.toast.failedSummary'),
detail: e.message || t('settings.notificationSettings.toast.failedDetail'),
life: 5000,
});
} finally {
saving.value = false;
@@ -81,12 +82,11 @@ const handleSave = async () => {
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Notifications</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.notifications.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose how you want to receive notifications and updates.
{{ t('settings.content.notifications.subtitle') }}
</p>
</div>
<AppButton
@@ -97,11 +97,10 @@ const handleSave = async () => {
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
{{ t('settings.notificationSettings.saveChanges') }}
</AppButton>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<div
v-for="type in notificationTypes"

View File

@@ -1,16 +1,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import PlayIcon from '@/components/icons/PlayIcon.vue';
import RepeatIcon from '@/components/icons/RepeatIcon.vue';
import VolumeOffIcon from '@/components/icons/VolumeOffIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import ImageIcon from '@/components/icons/ImageIcon.vue';
const toast = useAppToast();
const { t } = useI18n();
const playerSettings = ref({
autoplay: true,
@@ -31,15 +28,15 @@ const handleSave = async () => {
await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({
severity: 'success',
summary: 'Settings Saved',
detail: 'Your player settings have been saved.',
summary: t('settings.playerSettings.toast.savedSummary'),
detail: t('settings.playerSettings.toast.savedDetail'),
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Save Failed',
detail: e.message || 'Failed to save settings.',
summary: t('settings.playerSettings.toast.failedSummary'),
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
life: 5000
});
} finally {
@@ -47,50 +44,50 @@ const handleSave = async () => {
}
};
const settingsItems = [
const settingsItems = computed(() => [
{
key: 'autoplay' as const,
title: 'Autoplay',
description: 'Automatically start videos when loaded',
title: t('settings.playerSettings.items.autoplay.title'),
description: t('settings.playerSettings.items.autoplay.description'),
svg: `<svg class="w-6 h-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 404"><path d="M26 45v314c0 10 9 19 19 19 5 0 9-2 13-5l186-157c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19z" class="fill-primary/30"/><path d="M26 359c0 11 9 19 19 19 5 0 9-2 13-4l186-158c4-3 6-9 6-14s-2-11-6-14L58 31c-4-3-8-5-13-5-10 0-19 9-19 19v314zm-16 0V45c0-19 16-35 35-35 8 0 17 3 23 8l186 158c8 6 12 16 12 26s-4 20-12 26L68 386c-6 5-15 8-23 8-19 0-35-16-35-35zM378 18v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8zm144 0v368c0 4-4 8-8 8s-8-4-8-8V18c0-4 4-8 8-8s8 4 8 8z" class="fill-primary"/></svg>`,
},
{
key: 'loop' as const,
title: 'Loop',
description: 'Repeat video when it ends',
title: t('settings.playerSettings.items.loop.title'),
description: t('settings.playerSettings.items.loop.description'),
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 497 496"><path d="M80 248c0 30 8 58 21 82h65c8 0 14 6 14 14s-6 14-14 14h-45c31 36 76 58 127 58 93 0 168-75 168-168 0-30-8-58-21-82h-65c-8 0-14-6-14-14s6-14 14-14h45c-31-35-76-58-127-58-93 0-168 75-168 168z" class="fill-primary/30"/><path d="M70 358c37 60 103 100 179 100 116 0 210-94 210-210 0-8 6-14 14-14 7 0 14 6 14 14 0 131-107 238-238 238-82 0-154-41-197-104v90c0 8-6 14-14 14s-14-6-14-14V344c0-8 6-14 14-14h128c8 0 14 6 14 14s-6 14-14 14H70zm374-244V24c0-8 6-14 14-14s14 6 14 14v128c0 8-6 14-14 14H330c-8 0-14-6-14-14s6-14 14-14h96C389 78 323 38 248 38 132 38 38 132 38 248c0 8-7 14-14 14-8 0-14-6-14-14C10 117 116 10 248 10c81 0 153 41 196 104z" class="fill-primary"/></svg>`,
},
{
key: 'muted' as const,
title: 'Muted',
description: 'Start videos with sound muted',
title: t('settings.playerSettings.items.muted.title'),
description: t('settings.playerSettings.items.muted.description'),
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 549 468"><path d="M26 186v96c0 18 14 32 32 32h64c4 0 8 2 11 5l118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32z" class="fill-primary/30"/><path d="m133 319 118 118c4 3 8 5 13 5 10 0 18-8 18-18V44c0-10-8-18-18-18-5 0-9 2-13 5L133 149c-3 3-7 5-11 5H58c-18 0-32 14-32 32v96c0 18 14 32 32 32h64c4 0 8 2 11 5zM58 138h64L240 20c7-6 15-10 24-10 19 0 34 15 34 34v380c0 19-15 34-34 34-9 0-17-4-24-10L122 330H58c-26 0-48-21-48-48v-96c0-26 22-48 48-48zm322 18c3-3 9-3 12 0l66 67 66-67c3-3 8-3 12 0 3 3 3 9 0 12l-67 66 67 66c3 3 3 8 0 12-4 3-9 3-12 0l-66-67-66 67c-3 3-9 3-12 0-3-4-3-9 0-12l67-66-67-66c-3-3-3-9 0-12z" class="fill-primary"/></svg>`,
},
{
key: 'showControls' as const,
title: 'Show Controls',
description: 'Display player controls (play, pause, volume)',
title: t('settings.playerSettings.items.showControls.title'),
description: t('settings.playerSettings.items.showControls.description'),
svg: `<svg class="h6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M26 74v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74c0-26-21-48-48-48H74c-26 0-48 22-48 48zm48 72c0-4 4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8zm0 176c0-4 4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8s-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8z" class="fill-primary/30"/><path d="M442 74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h320c27 0 48-21 48-48V74zm16 320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h320c35 0 64 29 64 64v320zm-64-72c0 4-4 8-8 8h-56v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h184v-24c0-18 14-32 32-32s32 14 32 32v24h56c4 0 8 4 8 8zm-112 0v32c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16v32zm104-184c4 0 8 4 8 8s-4 8-8 8H202v24c0 18-14 32-32 32s-32-14-32-32v-24H82c-4 0-8-4-8-8s4-8 8-8h56v-24c0-18 14-32 32-32s32 14 32 32v24h184zm-232-24v64c0 9 7 16 16 16s16-7 16-16v-64c0-9-7-16-16-16s-16 7-16 16z" class="fill-primary"/></svg>`
},
{
key: 'pip' as const,
title: 'Picture in Picture',
description: 'Enable Picture-in-Picture mode',
title: t('settings.playerSettings.items.pip.title'),
description: t('settings.playerSettings.items.pip.description'),
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v112H314c-53 0-96 43-96 96v160H74c-26 0-48-21-48-48V74z" class="fill-primary/30"/><path d="M458 10c35 0 64 29 64 64v112h-16V74c0-26-21-48-48-48H74c-26 0-48 22-48 48v320c0 27 22 48 48 48h144v16H68c-31-3-55-27-58-57V74c0-33 25-60 58-64h390zm16 224c27 0 48 22 48 48v133c-3 24-23 43-48 43H309c-22-2-40-20-43-43V282c0-26 22-48 48-48h160zm-160 16c-18 0-32 14-32 32v128c0 18 14 32 32 32h160c18 0 32-14 32-32V282c0-18-14-32-32-32H314z" class="fill-primary"/></svg>`
},
{
key: 'airplay' as const,
title: 'AirPlay',
description: 'Allow streaming to Apple devices via AirPlay',
title: t('settings.playerSettings.items.airplay.title'),
description: t('settings.playerSettings.items.airplay.description'),
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 436"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v224c0 26-21 47-47 48-45-45-91-91-136-137-32-31-82-31-114 0-45 46-91 91-136 137-26-1-47-22-47-48V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v224c0 26 21 47 47 48l-14 14c-28-7-49-32-49-62V74c0-35 29-64 64-64h384c35 0 64 29 64 64v224c0 30-21 55-49 62l-14-14c26-1 47-22 47-48V74c0-26-21-48-48-48zM138 410h256c7 0 12-4 15-10 2-6 1-13-4-17L277 255c-6-6-16-6-22 0L127 383c-5 4-6 11-4 17 3 6 9 10 15 10zm279-39c9 10 12 23 7 35s-17 20-30 20H138c-13 0-25-8-30-20s-2-25 7-35l128-128c13-12 33-12 46 0l128 128z" class="fill-primary"/></svg>`,
},
{
key: 'Chromecast' as const,
title: 'Chromecast',
description: 'Allow casting to Chromecast devices',
title: t('settings.playerSettings.items.chromecast.title'),
description: t('settings.playerSettings.items.chromecast.description'),
svg: `<svg class="h-6 w-6" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 532 468"><path d="M26 74c0-26 22-48 48-48h384c27 0 48 22 48 48v320c0 27-21 48-48 48H314v-9c0-154-125-279-279-279h-9V74z" class="fill-primary/30"/><path d="M458 26H74c-26 0-48 22-48 48v80H10V74c0-35 29-64 64-64h384c35 0 64 29 64 64v320c0 35-29 64-64 64H314v-16h144c27 0 48-21 48-48V74c0-26-21-48-48-48zM18 202c137 0 248 111 248 248 0 4-4 8-8 8s-8-4-8-8c0-128-104-232-232-232-4 0-8-4-8-8s4-8 8-8zm40 224c0-9-7-16-16-16s-16 7-16 16 7 16 16 16 16-7 16-16zm-48 0c0-18 14-32 32-32s32 14 32 32-14 32-32 32-32-14-32-32zm0-120c0-4 4-8 8-8 84 0 152 68 152 152 0 4-4 8-8 8s-8-4-8-8c0-75-61-136-136-136-4 0-8-4-8-8z" class="fill-primary"/></svg>`,
},
];
]);
</script>
<template>
@@ -98,9 +95,9 @@ const settingsItems = [
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Player Settings</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.content.player.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Configure default video player behavior and features.
{{ t('settings.content.player.subtitle') }}
</p>
</div>
<AppButton
@@ -111,7 +108,7 @@ const settingsItems = [
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
{{ t('common.save') }}
</AppButton>
</div>

View File

@@ -7,11 +7,30 @@ import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { supportedLocales, type SupportedLocale } from '@/i18n/constants';
import { normalizeLocale } from '@/i18n';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { ref } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const auth = useAuthStore();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useI18n();
const selectedLanguage = ref<SupportedLocale>(normalizeLocale((auth.user as any)?.language ?? (auth.user as any)?.locale));
const languageSaving = ref(false);
const languageOptions = computed(() => supportedLocales.map((value) => ({
value,
label: t(`settings.securityConnected.language.options.${value}`)
})));
watch(() => auth.user, (nextUser) => {
selectedLanguage.value = normalizeLocale((nextUser as any)?.language ?? (nextUser as any)?.locale);
}, { deep: true });
// 2FA state
const twoFactorEnabled = ref(false);
@@ -45,12 +64,12 @@ const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = 'Passwords do not match';
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.mismatch');
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = 'Password must be at least 6 characters';
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.minLength');
return;
}
@@ -63,17 +82,54 @@ const changePassword = async () => {
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
summary: t('settings.securityConnected.changePassword.toast.successSummary'),
detail: t('settings.securityConnected.changePassword.toast.successDetail'),
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || 'Failed to change password';
changePasswordError.value = e.message || t('settings.securityConnected.changePassword.dialog.errors.default');
} finally {
changePasswordLoading.value = false;
}
};
const handleLogout = () => {
confirm.require({
message: t('settings.securityConnected.logout.confirm.message'),
header: t('settings.securityConnected.logout.confirm.header'),
acceptLabel: t('settings.securityConnected.logout.confirm.accept'),
rejectLabel: t('settings.securityConnected.logout.confirm.reject'),
accept: async () => {
await auth.logout();
}
});
};
const saveLanguage = async () => {
languageSaving.value = true;
try {
const result = await auth.setLanguage(selectedLanguage.value);
if (result.ok && !result.fallbackOnly) {
toast.add({
severity: 'success',
summary: t('settings.securityConnected.language.toast.successSummary'),
detail: t('settings.securityConnected.language.toast.successDetail'),
life: 3000,
});
return;
}
toast.add({
severity: 'warn',
summary: t('settings.securityConnected.language.toast.errorSummary'),
detail: t('settings.securityConnected.language.toast.errorDetail'),
life: 5000,
});
} finally {
languageSaving.value = false;
}
};
// Toggle 2FA
const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) {
@@ -83,8 +139,8 @@ const handleToggle2FA = async () => {
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Failed to enable two-factor authentication.',
summary: t('settings.securityConnected.toast.twoFactorEnableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = false;
@@ -94,15 +150,15 @@ const handleToggle2FA = async () => {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: '2FA Disabled',
detail: 'Two-factor authentication has been disabled.',
summary: t('settings.securityConnected.toast.twoFactorDisabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disable 2FA Failed',
detail: 'Failed to disable two-factor authentication.',
summary: t('settings.securityConnected.toast.twoFactorDisableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = true;
@@ -117,15 +173,15 @@ const confirmTwoFactor = async () => {
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: '2FA Enabled',
detail: 'Two-factor authentication has been enabled successfully.',
summary: t('settings.securityConnected.toast.twoFactorEnabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Invalid verification code. Please try again.',
summary: t('settings.securityConnected.toast.twoFactorInvalidCodeSummary'),
detail: t('settings.securityConnected.toast.twoFactorInvalidCodeDetail'),
life: 5000
});
}
@@ -139,15 +195,15 @@ const connectTelegram = async () => {
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: 'Telegram Connected',
detail: `Connected to ${telegramUsername.value}`,
summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Connection Failed',
detail: 'Failed to connect Telegram account.',
summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
life: 5000
});
}
@@ -161,15 +217,15 @@ const disconnectTelegram = async () => {
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: 'Telegram Disconnected',
detail: 'Your Telegram account has been disconnected.',
summary: t('settings.securityConnected.toast.telegramDisconnectedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectedDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disconnect Failed',
detail: 'Failed to disconnect Telegram account.',
summary: t('settings.securityConnected.toast.telegramDisconnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectFailedDetail'),
life: 5000
});
}
@@ -180,9 +236,9 @@ const disconnectTelegram = async () => {
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<h2 class="text-base font-semibold text-foreground">{{ t('settings.securityConnected.header.title') }}</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
{{ t('settings.securityConnected.header.subtitle') }}
</p>
</div>
@@ -198,11 +254,52 @@ const disconnectTelegram = async () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.accountStatus.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.accountStatus.detail') }}</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</div>
<!-- Language -->
<div class="flex items-center justify-between gap-4 px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.language.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.language.detail') }}</p>
</div>
</div>
<div class="flex items-center gap-2">
<select
v-model="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground disabled:opacity-60"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
:loading="languageSaving"
:disabled="languageSaving"
@click="saveLanguage"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</div>
</div>
<!-- Two-Factor Authentication -->
@@ -212,9 +309,9 @@ const disconnectTelegram = async () => {
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactor.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
{{ twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled') }}
</p>
</div>
</div>
@@ -230,12 +327,31 @@ const disconnectTelegram = async () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<p class="text-xs text-foreground/60 mt-0.5">Update your account password</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.changePassword.detail') }}</p>
</div>
</div>
<AppButton size="sm" @click="openChangePassword">
Change Password
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</div>
<!-- Logout -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<XCircleIcon class="w-5 h-5 text-danger" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.logout.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ t('settings.securityConnected.logout.detail') }}</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleLogout">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</div>
@@ -249,14 +365,14 @@ const disconnectTelegram = async () => {
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.email.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
{{ emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected') }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</div>
@@ -267,9 +383,9 @@ const disconnectTelegram = async () => {
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.telegram.label') }}</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
{{ telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected') }}
</p>
</div>
</div>
@@ -279,14 +395,14 @@ const disconnectTelegram = async () => {
size="sm"
@click="disconnectTelegram"
>
Disconnect
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="connectTelegram"
>
Connect
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</div>
</div>
@@ -295,12 +411,12 @@ const disconnectTelegram = async () => {
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
title="Enable Two-Factor Authentication"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<!-- QR Code Placeholder -->
@@ -317,17 +433,17 @@ const disconnectTelegram = async () => {
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
@@ -335,13 +451,13 @@ const disconnectTelegram = async () => {
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
Cancel
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Verify & Enable
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
@@ -351,12 +467,12 @@ const disconnectTelegram = async () => {
<AppDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
title="Change Password"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<!-- Error Message -->
@@ -366,12 +482,12 @@ const disconnectTelegram = async () => {
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="Enter current password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
@@ -381,12 +497,12 @@ const disconnectTelegram = async () => {
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
placeholder="Enter new password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
@@ -396,12 +512,12 @@ const disconnectTelegram = async () => {
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
@@ -417,7 +533,7 @@ const disconnectTelegram = async () => {
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
Cancel
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
@@ -427,7 +543,7 @@ const disconnectTelegram = async () => {
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>

View File

@@ -1,12 +1,14 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUploadQueue } from '@/composables/useUploadQueue';
import { useUIState } from '@/stores/uiState';
import { ref } from 'vue';
import RemoteUrlForm from './components/RemoteUrlForm.vue';
import UploadDropzone from './components/UploadDropzone.vue';
const uiState = useUIState();
const mode = ref<'local' | 'remote'>('local');
const { t } = useI18n();
const { addFiles, addRemoteUrls, pendingCount, startQueue, remainingSlots, maxItems } = useUploadQueue();
@@ -15,8 +17,10 @@ const handleFilesSelected = (files: FileList) => {
if (result.duplicates > 0) {
uiState.toastQueue.push({
severity: 'warn',
summary: 'Duplicate files skipped',
detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
summary: t('upload.dialog.duplicateFilesSummary'),
detail: result.duplicates > 1
? t('upload.dialog.duplicateFilesDetailOther', { count: result.duplicates })
: t('upload.dialog.duplicateFilesDetailOne', { count: result.duplicates }),
life: 4000,
});
}
@@ -28,8 +32,10 @@ const handleRemoteUrls = (urls: string[]) => {
if (result.duplicates > 0) {
uiState.toastQueue.push({
severity: 'warn',
summary: 'Duplicate URLs skipped',
detail: `${result.duplicates} URL${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
summary: t('upload.dialog.duplicateUrlsSummary'),
detail: result.duplicates > 1
? t('upload.dialog.duplicateUrlsDetailOther', { count: result.duplicates })
: t('upload.dialog.duplicateUrlsDetailOne', { count: result.duplicates }),
life: 4000,
});
}
@@ -49,7 +55,6 @@ const handleStartUpload = () => {
max-width-class="max-w-[580px] w-full"
>
<template #header="{ close }">
<!-- Header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-accent/10 flex items-center justify-center shrink-0">
@@ -62,28 +67,25 @@ const handleStartUpload = () => {
</svg>
</div>
<div>
<h2 class="font-bold text-base text-slate-900 leading-tight">Upload Videos</h2>
<p class="text-sm text-slate-400 leading-tight mt-0.5">Add up to {{ maxItems }} videos per batch</p>
<h2 class="font-bold text-base text-slate-900 leading-tight">{{ t('upload.dialog.title') }}</h2>
<p class="text-sm text-slate-400 leading-tight mt-0.5">{{ t('upload.dialog.subtitle', { maxItems }) }}</p>
</div>
</div>
<!-- Mode switcher -->
<div class="flex items-center gap-0.5 bg-slate-100 rounded-xl p-1">
<button @click="mode = 'local'"
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'local' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
Local
{{ t('upload.dialog.mode.local') }}
</button>
<button @click="mode = 'remote'"
:class="['px-4 py-2 text-sm font-medium rounded-lg transition-all', mode === 'remote' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700']">
Remote URL
{{ t('upload.dialog.mode.remote') }}
</button>
</div>
</div>
</template>
<!-- Input area -->
<div class="h-[320px]">
<!-- Queue full warning -->
<div v-if="remainingSlots === 0"
class="h-full flex flex-col items-center justify-center gap-4 text-center">
<div class="w-16 h-16 rounded-2xl bg-amber-50 flex items-center justify-center">
@@ -96,14 +98,13 @@ const handleStartUpload = () => {
</svg>
</div>
<div>
<p class="text-base font-semibold text-slate-700">Queue is full</p>
<p class="text-base font-semibold text-slate-700">{{ t('upload.dialog.queueFullTitle') }}</p>
<p class="text-sm text-slate-400 mt-1">
Maximum {{ maxItems }} videos per batch.<br>Start or clear the current queue first.
{{ t('upload.dialog.queueFullDescription', { maxItems }) }}
</p>
</div>
</div>
<!-- Dropzone / URL form -->
<Transition v-else enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1" enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
@@ -114,20 +115,17 @@ const handleStartUpload = () => {
</div>
<template #footer>
<!-- Footer -->
<div class="flex items-center justify-between">
<span class="text-sm text-slate-400">
<span v-if="remainingSlots < maxItems">
<span class="font-semibold"
:class="remainingSlots === 0 ? 'text-amber-500' : 'text-slate-600'">{{ remainingSlots }}</span>
/ {{ maxItems }} slots remaining
{{ t('upload.dialog.slotsRemaining', { remaining: remainingSlots, maxItems }) }}
</span>
<span v-else>MP4, MOV, MKV · max 10 GB per file</span>
<span v-else>{{ t('upload.dialog.formatsHint') }}</span>
</span>
<div class="flex items-center gap-2">
<button @click="uiState.uploadDialogVisible = false"
class="px-5 py-2.5 text-sm font-medium text-slate-600 hover:text-slate-800 hover:bg-slate-100 rounded-xl transition-all">
Close
{{ t('common.close') }}
</button>
<button v-if="pendingCount > 0" @click="handleStartUpload"
class="flex items-center gap-2 px-5 py-2.5 bg-accent hover:bg-accent/90 text-white text-sm font-semibold rounded-xl transition-all shadow-sm shadow-accent/30">
@@ -138,7 +136,7 @@ const handleStartUpload = () => {
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
{{ t('upload.dialog.startUpload', { count: pendingCount }) }}
</button>
</div>
</div>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
defineProps<{
pendingCount?: number;
@@ -8,6 +9,7 @@ defineProps<{
const category = ref('');
const visibility = ref('public');
const { t } = useI18n();
</script>
<template>
@@ -15,20 +17,20 @@ const visibility = ref('public');
style="transition: all 500ms;">
<div class="p-6 bg-indigo-50/50 rounded-3xl border border-indigo-100/50 flex items-center justify-between">
<div>
<h4 class="text-lg font-semibold text-slate-900">Quick Settings</h4>
<p class="text-slate-500 text-sm">Apply to {{ pendingCount || 0 }} pending files</p>
<h4 class="text-lg font-semibold text-slate-900">{{ t('upload.bulkActions.title') }}</h4>
<p class="text-slate-500 text-sm">{{ t('upload.bulkActions.applyToPending', { count: pendingCount || 0 }) }}</p>
</div>
<div class="flex gap-3">
<select v-model="category"
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
<option value="">Select category...</option>
<option value="learning">Learning</option>
<option value="entertainment">Entertainment</option>
<option value="">{{ t('upload.bulkActions.selectCategory') }}</option>
<option value="learning">{{ t('upload.bulkActions.category.learning') }}</option>
<option value="entertainment">{{ t('upload.bulkActions.category.entertainment') }}</option>
</select>
<select v-model="visibility"
class="px-4 py-2.5 bg-white border-2 border-slate-200 rounded-xl text-sm font-medium text-slate-700 focus:border-accent outline-none transition">
<option value="public">Public</option>
<option value="private">Private</option>
<option value="public">{{ t('upload.bulkActions.visibility.public') }}</option>
<option value="private">{{ t('upload.bulkActions.visibility.private') }}</option>
</select>
</div>
</div>

View File

@@ -1,4 +1,7 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
</script>
<template>
@@ -11,10 +14,8 @@
<path d="M12 8h.01"></path>
</svg>
<div class="flex-1 text-sm">
<p class="font-medium text-blue-900 dark:text-blue-100 mb-1">Tip: For fastest processing</p>
<p class="text-blue-800 dark:text-blue-200">Upload videos in <strong>H.264 video codec + AAC
audio codec</strong> format (e.g., MP4 with H.264/AAC). Videos in this format will be
processed much faster (seconds instead of minutes) because they don't need re-encoding.</p>
<p class="font-medium text-blue-900 dark:text-blue-100 mb-1">{{ t('upload.infoTip.title') }}</p>
<p class="text-blue-800 dark:text-blue-200">{{ t('upload.infoTip.description') }}</p>
</div>
</div>
</template>

View File

@@ -1,10 +1,12 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{ maxUrls?: number }>();
const urls = ref('');
const emit = defineEmits<{ submit: [urls: string[]] }>();
const { t } = useI18n();
const handleSubmit = () => {
const limit = props.maxUrls ?? 5;
@@ -25,7 +27,7 @@ const handleSubmit = () => {
<div class="relative flex-1">
<textarea
v-model="urls"
placeholder="Paste video URLs here, one per line&#10;&#10;https://example.com/video.mp4&#10;https://drive.google.com/..."
:placeholder="t('upload.remote.placeholder')"
class="w-full h-full min-h-[200px] px-4 py-3.5 bg-white border border-slate-200
rounded-xl focus:border-accent focus:ring-2 focus:ring-accent/10 focus:outline-none
transition-all resize-none text-base text-slate-700 placeholder:text-slate-300
@@ -49,7 +51,7 @@ const handleSubmit = () => {
<path d="M12 16v-4" />
<path d="M12 8h.01" />
</svg>
Google Drive, Dropbox supported
{{ t('upload.remote.providersHint') }}
</div>
<button
@click="handleSubmit"
@@ -69,7 +71,7 @@ const handleSubmit = () => {
<path d="M5 12h14" />
<path d="m12 5 7 7-7 7" />
</svg>
Add URLs
{{ t('upload.remote.addUrls') }}
</button>
</div>
</div>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{ maxFiles?: number }>();
const emit = defineEmits<{ filesSelected: [files: FileList] }>();
const { t } = useI18n();
const isDragOver = ref(false);
let dragCounter = 0;
@@ -91,9 +93,9 @@ const onDrop = (e: DragEvent) => {
<div class="text-center">
<p :class="['text-base font-semibold transition-colors', isDragOver ? 'text-accent' : 'text-slate-700 group-hover:text-slate-900']">
{{ isDragOver ? 'Release to add' : 'Drop videos here' }}
{{ isDragOver ? t('upload.dropzone.releaseToAdd') : t('upload.dropzone.dropHere') }}
</p>
<p class="text-sm text-slate-400 mt-1.5">or click anywhere to browse</p>
<p class="text-sm text-slate-400 mt-1.5">{{ t('upload.dropzone.browse') }}</p>
</div>
<!-- Format badges -->

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
modelValue: 'local' | 'remote';
@@ -10,18 +11,20 @@ const emit = defineEmits<{
'update:modelValue': [value: 'local' | 'remote'];
}>();
const modeList: { id: 'local' | 'remote'; label: string; icon: string }[] = [
const { t } = useI18n();
const modeList = computed<{ id: 'local' | 'remote'; label: string; icon: string }[]>(() => [
{
id: 'local',
label: 'Local Upload',
label: t('upload.dialog.mode.local'),
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 0 1 7.38 16.75"/><path d="M12 8v8"/><path d="m8 12 4-4 4 4"/><path d="M2.5 8.875a10 10 0 0 0-.5 3"/><path d="M2.83 16a10 10 0 0 0 2.43 3.4"/><path d="M4.636 5.235a10 10 0 0 1 .891-.857"/><rect width="6" height="6" x="16" y="16" rx="1"/></svg>`
},
{
id: 'remote',
label: 'Remote URL',
label: t('upload.dialog.mode.remote'),
icon: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>`
}
];
]);
const mode = computed({
get: () => props.modelValue,

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import UploadQueueItem from './UploadQueueItem.vue';
import type { QueueItem } from '@/composables/useUploadQueue';
import { useI18n } from 'vue-i18n';
defineProps<{
items?: QueueItem[];
@@ -15,6 +16,8 @@ const emit = defineEmits<{
publish: [];
startQueue: [];
}>()
const { t } = useI18n();
</script>
<template>
@@ -33,7 +36,7 @@ const emit = defineEmits<{
<path d="M3 9h18" />
<path d="M9 21V9" />
</svg>
<p class="text-slate-400 font-medium">Empty queue!</p>
<p class="text-slate-400 font-medium">{{ t('upload.queue.empty') }}</p>
</div>
@@ -41,8 +44,8 @@ const emit = defineEmits<{
<div class="p-6 border-t border-border shrink-0">
<div class="flex items-center justify-between text-sm mb-4 font-medium">
<span class="text-slate-500">Total size:</span>
<span class="text-slate-900">{{ totalSize || '0 MB' }}</span>
<span class="text-slate-500">{{ t('upload.queue.totalSize') }}</span>
<span class="text-slate-900">{{ totalSize || t('upload.queue.zeroSize') }}</span>
</div>
<button :disabled="!!(!pendingCount || pendingCount < 1)" @click="emit('startQueue')"
@@ -53,7 +56,7 @@ const emit = defineEmits<{
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Start Upload ({{ pendingCount }})
{{ t('upload.dialog.startUpload', { count: pendingCount || 0 }) }}
</button>
</div>
</div>

View File

@@ -2,6 +2,7 @@
import FileUploadType from '@/components/icons/FileUploadType.vue';
import type { QueueItem } from '@/composables/useUploadQueue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
item: QueueItem;
@@ -12,14 +13,18 @@ const emit = defineEmits<{
cancel: [id: string];
}>();
const { t } = useI18n();
const statusLabel = computed(() => {
switch (props.item.status) {
case 'pending': return 'Pending';
case 'uploading': return props.item.activeChunks ? `Uploading · ${props.item.activeChunks} threads` : 'Uploading...';
case 'processing': return 'Processing...';
case 'complete': return 'Done';
case 'error': return 'Failed';
case 'fetching': return 'Fetching...';
case 'pending': return t('upload.queueItem.status.pending');
case 'uploading': return props.item.activeChunks
? t('upload.queueItem.status.uploadingThreads', { threads: props.item.activeChunks })
: t('upload.queueItem.status.uploading');
case 'processing': return t('upload.queueItem.status.processing');
case 'complete': return t('upload.queueItem.status.complete');
case 'error': return t('upload.queueItem.status.error');
case 'fetching': return t('upload.queueItem.status.fetching');
default: return props.item.status;
}
});
@@ -103,7 +108,7 @@ const progress = computed(() => props.item.progress || 0);
<!-- Cancel button -->
<button v-if="canCancel" @click="emit('cancel', item.id)"
class="text-[10px] font-medium text-slate-400 hover:text-red-500 transition-colors">
Cancel
{{ t('common.cancel') }}
</button>
</div>
</div>

View File

@@ -3,6 +3,7 @@ import type { ModelVideo } from '@/api/client';
import { fetchMockVideoById } from '@/mocks/videos';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
videoId: string;
@@ -16,6 +17,7 @@ const toast = useAppToast();
const video = ref<ModelVideo | null>(null);
const loading = ref(true);
const copiedField = ref<string | null>(null);
const { t } = useI18n();
const fetchVideo = async () => {
loading.value = true;
@@ -26,7 +28,12 @@ const fetchVideo = async () => {
}
} catch (error) {
console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.copyModal.toastErrorSummary'),
detail: t('video.copyModal.toastErrorDetail'),
life: 3000
});
} finally {
loading.value = false;
}
@@ -40,20 +47,20 @@ const shareLinks = computed(() => {
return [
{
key: 'embed',
label: 'Embed player (recommended)',
label: t('video.copyModal.embedPlayer'),
value: `${baseUrl.value}/play/index/${v.id}`,
},
{
key: 'thumbnail',
label: 'Thumbnail URL',
label: t('video.copyModal.thumbnail'),
value: v.thumbnail || '',
},
{
key: 'hls',
label: 'HLS link (VIP only)',
label: t('video.copyModal.hls'),
value: v.hls_path ? `${baseUrl.value}/hls/getlink/${v.id}/${v.hls_token}/${v.hls_path}` : '',
placeholder: 'HLS link available for VIP with whitelisted domain',
hint: 'This link redirects to a signed HLS URL and only works on whitelisted domains.',
placeholder: t('video.copyModal.hlsPlaceholder'),
hint: t('video.copyModal.hlsHint'),
},
];
});
@@ -70,7 +77,12 @@ const copyToClipboard = async (text: string, key: string) => {
document.body.removeChild(textArea);
}
copiedField.value = key;
toast.add({ severity: 'success', summary: 'Copied', detail: 'Copied to clipboard', life: 2000 });
toast.add({
severity: 'success',
summary: t('video.copyModal.toastCopiedSummary'),
detail: t('video.copyModal.toastCopiedDetail'),
life: 2000
});
setTimeout(() => {
copiedField.value = null;
}, 2000);
@@ -87,7 +99,7 @@ watch(() => props.videoId, (newId) => {
<template>
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:title="loading ? '' : 'Get sharing address'">
:title="loading ? '' : t('video.copyModal.title')">
<!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-5">
@@ -111,7 +123,7 @@ watch(() => props.videoId, (newId) => {
<div v-else class="flex flex-col gap-5">
<!-- Player addresses -->
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Player address</p>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{{ t('video.copyModal.playerAddress') }}</p>
<div class="flex flex-col gap-4">
<div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5">
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
@@ -143,18 +155,15 @@ watch(() => props.videoId, (newId) => {
<!-- Notices -->
<div class="flex flex-col gap-2 text-sm">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm">
<p class="font-medium text-red-900 dark:text-red-100 mb-1">Warning</p>
<p class="text-red-800 dark:text-red-200">Make sure shared files comply with <strong>local laws</strong> and confirm you understand the responsibilities involved when distributing content.</p>
<p class="font-medium text-red-900 dark:text-red-100 mb-1">{{ t('video.copyModal.warningTitle') }}</p>
<p class="text-red-800 dark:text-red-200">{{ t('video.copyModal.warningDetail') }}</p>
</div>
</div>
<div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100 mb-1">Reminder</p>
<p class="text-amber-800 dark:text-amber-200">The embed player can auto switch fallback nodes and works well on mobile. Raw HLS links
rely on your own player and must be used only on whitelisted domains.</p>
<p class="font-medium text-amber-900 dark:text-amber-100 mb-1">{{ t('video.copyModal.reminderTitle') }}</p>
<p class="text-amber-800 dark:text-amber-200">{{ t('video.copyModal.reminderDetail') }}</p>
</div>
</div>
</div>

View File

@@ -4,7 +4,8 @@ import PageHeader from '@/components/dashboard/PageHeader.vue';
import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { onMounted, ref } from 'vue';
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import VideoEditForm from './components/Detail/VideoEditForm.vue';
import VideoHeader from './components/Detail/VideoInfoHeader.vue';
@@ -15,6 +16,7 @@ const route = useRoute();
const router = useRouter();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useI18n();
const videoId = route.params.id as string;
const video = ref<ModelVideo | null>(null);
@@ -38,7 +40,12 @@ const fetchVideo = async () => {
}
} catch (error) {
console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.detailModal.toast.loadErrorSummary'),
detail: t('video.detailModal.toast.loadErrorDetail'),
life: 3000
});
router.push('/video');
} finally {
loading.value = false;
@@ -46,7 +53,12 @@ const fetchVideo = async () => {
};
const handleReload = async () => {
toast.add({ severity: 'info', summary: 'Info', detail: 'Reloading video...', life: 2000 });
toast.add({
severity: 'info',
summary: t('video.detailPage.toast.reloadSummary'),
detail: t('video.detailPage.toast.reloadDetail'),
life: 2000
});
await fetchVideo();
};
@@ -68,11 +80,21 @@ const handleSave = async () => {
video.value.description = form.value.description;
}
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
toast.add({
severity: 'success',
summary: t('video.detailModal.toast.saveSuccessSummary'),
detail: t('video.detailModal.toast.saveSuccessDetail'),
life: 3000
});
isEditing.value = false;
} catch (error) {
console.error('Failed to save video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.detailModal.toast.saveErrorSummary'),
detail: t('video.detailModal.toast.saveErrorDetail'),
life: 3000
});
} finally {
saving.value = false;
}
@@ -80,18 +102,28 @@ const handleSave = async () => {
const handleDelete = () => {
confirm.require({
message: 'Are you sure you want to delete this video? This action cannot be undone.',
header: 'Confirm Delete',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
message: t('video.detailPage.confirmDelete.message'),
header: t('video.detailPage.confirmDelete.header'),
acceptLabel: t('video.detailPage.confirmDelete.accept'),
rejectLabel: t('video.detailPage.confirmDelete.reject'),
accept: async () => {
try {
await deleteMockVideo(videoId);
toast.add({ severity: 'success', summary: 'Success', detail: 'Video deleted successfully', life: 3000 });
toast.add({
severity: 'success',
summary: t('video.detailPage.toast.deleteSuccessSummary'),
detail: t('video.detailPage.toast.deleteSuccessDetail'),
life: 3000
});
router.push('/video');
} catch (error) {
console.error('Failed to delete video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete video', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.detailPage.toast.deleteErrorSummary'),
detail: t('video.detailPage.toast.deleteErrorDetail'),
life: 3000
});
}
},
reject: () => { }
@@ -101,7 +133,6 @@ const handleDelete = () => {
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
@@ -109,32 +140,45 @@ const copyToClipboard = async (text: string, label: string) => {
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
}
toast.add({
severity: 'success',
summary: t('video.detailPage.toast.copySummary'),
detail: t('video.detailPage.toast.copyDetail', { label }),
life: 2000
});
};
const origin = computed(() => typeof window !== 'undefined' ? window.location.origin : '');
const videoInfos = computed(() => {
if (!video.value) return [];
const embedUrl = `${origin.value}/embed/${video.value.id}`;
return [
{ label: t('video.detailPage.videoInfo.videoId'), value: video.value.id ?? '' },
{ label: t('video.detailPage.videoInfo.thumbnailUrl'), value: video.value.thumbnail ?? '' },
{ label: t('video.detailPage.videoInfo.embedUrl'), value: embedUrl },
{
label: t('video.detailPage.videoInfo.iframeCode'),
value: embedUrl ? `<iframe src="${embedUrl}" title="${video.value.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : ''
},
{ label: t('video.detailPage.videoInfo.shareLink'), value: `${origin.value}/view/${video.value.id}` },
];
});
onMounted(() => {
fetchVideo();
});
const videoInfos = computed(() => {
if (!video) return [];
const embedUrl = video ? `${window.location.origin}/embed/${video.value?.id}` : '';
return [
{ label: 'Video ID', value: video.value?.id ?? '' },
{ label: 'Thumbnail URL', value: video.value?.thumbnail ?? '' },
{ label: 'Embed URL', value: embedUrl },
{ label: 'Iframe Code', value: embedUrl ? `<iframe src="${embedUrl}" title="${video.value?.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
{ label: 'Share Link', value: video ? `${window.location.origin}/view/${video.value?.id}` : '' },
];
});
</script>
<template>
<div>
<PageHeader title="Video Detail" description="View and manage video details" :breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Videos', to: '/video' },
{ label: video?.title || 'Loading...' }
<PageHeader :title="t('video.detailPage.title')" :description="t('video.detailPage.description')" :breadcrumbs="[
{ label: t('pageHeader.dashboard'), to: '/' },
{ label: t('nav.videos'), to: '/video' },
{ label: video?.title || t('video.detailPage.loadingBreadcrumb') }
]" />
<div class="mx-auto p-4 w-full">
@@ -150,10 +194,9 @@ const videoInfos = computed(() => {
<VideoEditForm v-if="isEditing" v-model:title="form.title"
v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" />
<div v-else>
<VideoHeader :video="video" @reload="handleReload"
@toggle-edit="toggleEdit" @delete="handleDelete" />
<VideoHeader :video="video" @reload="handleReload" @toggle-edit="toggleEdit" @delete="handleDelete" />
<div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3>
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ t('video.detailPage.detailsTitle') }}</h3>
<div class="flex flex-col gap-2">
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
<div>
@@ -166,7 +209,7 @@ const videoInfos = computed(() => {
<button v-if="info.value"
@click="copyToClipboard(info.value, info.label)"
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
title="Copy value">
:title="t('video.detailPage.copyValueTitle')">
<svg class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"

View File

@@ -3,6 +3,7 @@ import type { ModelVideo } from '@/api/client';
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import { useAppToast } from '@/composables/useAppToast';
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
videoId: string;
@@ -16,6 +17,7 @@ const toast = useAppToast();
const video = ref<ModelVideo | null>(null);
const loading = ref(true);
const saving = ref(false);
const { t } = useI18n();
const form = ref({
title: '',
@@ -43,7 +45,12 @@ const fetchVideo = async () => {
}
} catch (error) {
console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.detailModal.toast.loadErrorSummary'),
detail: t('video.detailModal.toast.loadErrorDetail'),
life: 3000
});
} finally {
loading.value = false;
}
@@ -52,7 +59,7 @@ const fetchVideo = async () => {
const validate = (): boolean => {
errors.value = {};
if (!form.value.title.trim()) {
errors.value.title = 'Title is required.';
errors.value.title = t('video.detailModal.errors.titleRequired');
}
return Object.keys(errors.value).length === 0;
};
@@ -68,11 +75,21 @@ const onFormSubmit = async () => {
video.value.description = form.value.description;
}
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
toast.add({
severity: 'success',
summary: t('video.detailModal.toast.saveSuccessSummary'),
detail: t('video.detailModal.toast.saveSuccessDetail'),
life: 3000
});
emit('close');
} catch (error) {
console.error('Failed to save video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
toast.add({
severity: 'error',
summary: t('video.detailModal.toast.saveErrorSummary'),
detail: t('video.detailModal.toast.saveErrorDetail'),
life: 3000
});
} finally {
saving.value = false;
}
@@ -89,7 +106,12 @@ const canUploadSubtitle = computed(() => {
const handleUploadSubtitle = () => {
if (!canUploadSubtitle.value) return;
toast.add({ severity: 'info', summary: 'Info', detail: 'Subtitle upload not yet implemented', life: 3000 });
toast.add({
severity: 'info',
summary: t('video.detailModal.toast.subtitleInfoSummary'),
detail: t('video.detailModal.toast.subtitleInfoDetail'),
life: 3000
});
};
watch(() => props.videoId, (newId) => {
@@ -102,7 +124,7 @@ watch(() => props.videoId, (newId) => {
<template>
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:title="loading ? '' : 'Edit video'">
:title="loading ? '' : t('video.detailModal.title')">
<!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-4">
@@ -144,15 +166,15 @@ watch(() => props.videoId, (newId) => {
<form v-else @submit.prevent="onFormSubmit" class="flex flex-col gap-4">
<!-- Title -->
<div class="flex flex-col gap-1">
<label for="edit-title" class="text-sm font-medium">Title</label>
<AppInput id="edit-title" v-model="form.title" placeholder="Enter video title" />
<label for="edit-title" class="text-sm font-medium">{{ t('video.detailModal.titleLabel') }}</label>
<AppInput id="edit-title" v-model="form.title" :placeholder="t('video.detailModal.titlePlaceholder')" />
<p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
</div>
<!-- Description -->
<div class="flex flex-col gap-1">
<label for="edit-description" class="text-sm font-medium">Description</label>
<textarea id="edit-description" v-model="form.description" placeholder="Enter video description"
<label for="edit-description" class="text-sm font-medium">{{ t('video.detailModal.descriptionLabel') }}</label>
<textarea id="edit-description" v-model="form.description" :placeholder="t('video.detailModal.descriptionPlaceholder')"
rows="4"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y" />
<p v-if="errors.description" class="text-xs text-red-500 mt-0.5">{{ errors.description }}</p>
@@ -161,20 +183,20 @@ watch(() => props.videoId, (newId) => {
<!-- Subtitles Section -->
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4">
<div class="flex items-center justify-between">
<label class="text-sm font-medium">Subtitles</label>
<label class="text-sm font-medium">{{ t('video.detailModal.subtitlesTitle') }}</label>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
0 tracks
{{ t('video.detailModal.subtitleTracks', { count: 0 }) }}
</span>
</div>
<p class="text-sm text-muted-foreground">No subtitles uploaded yet</p>
<p class="text-sm text-muted-foreground">{{ t('video.detailModal.noSubtitles') }}</p>
<!-- Upload Subtitle Form -->
<div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3">
<label class="text-sm font-medium">Upload Subtitle</label>
<label class="text-sm font-medium">{{ t('video.detailModal.uploadSubtitle') }}</label>
<div class="flex flex-col gap-1">
<label for="subtitle-file" class="text-xs font-medium">
Subtitle File (VTT, SRT, ASS, SSA)
{{ t('video.detailModal.subtitleFile') }}
</label>
<input id="subtitle-file" type="file"
accept=".vtt,.srt,.ass,.ssa,text/vtt,text/srt,application/x-subrip"
@@ -184,27 +206,27 @@ watch(() => props.videoId, (newId) => {
<div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1">
<label for="subtitle-language" class="text-xs font-medium">Language Code *</label>
<AppInput id="subtitle-language" v-model="subtitleForm.language" placeholder="en, vi, etc."
<label for="subtitle-language" class="text-xs font-medium">{{ t('video.detailModal.languageCode') }}</label>
<AppInput id="subtitle-language" v-model="subtitleForm.language" :placeholder="t('video.detailModal.languagePlaceholder')"
:maxlength="10" />
</div>
<div class="flex flex-col gap-1">
<label for="subtitle-name" class="text-xs font-medium">Display Name (Optional)</label>
<label for="subtitle-name" class="text-xs font-medium">{{ t('video.detailModal.displayName') }}</label>
<AppInput id="subtitle-name" v-model="subtitleForm.displayName"
placeholder="English, Tiếng Việt, etc." />
:placeholder="t('video.detailModal.displayNamePlaceholder')" />
</div>
</div>
<AppButton variant="secondary" class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle">
Upload Subtitle
{{ t('video.detailModal.uploadSubtitleButton') }}
</AppButton>
</div>
</div>
<!-- Footer inside Form so submit works -->
<div class="flex justify-end gap-2 border-t border-gray-200 pt-4">
<AppButton variant="ghost" type="button" @click="emit('close')">Cancel</AppButton>
<AppButton type="submit" :loading="saving">Save Changes</AppButton>
<AppButton variant="ghost" type="button" @click="emit('close')">{{ t('video.detailModal.cancel') }}</AppButton>
<AppButton type="submit" :loading="saving">{{ t('video.detailModal.saveChanges') }}</AppButton>
</div>
</form>
</AppDialog>

View File

@@ -3,7 +3,8 @@ import { type ModelVideo } from '@/api/client';
import EmptyState from '@/components/dashboard/EmptyState.vue';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { fetchMockVideos } from '@/mocks/videos';
import { createStaticVNode, onMounted, onUnmounted, ref, watch } from 'vue';
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import { useUploadQueue } from '@/composables/useUploadQueue';
@@ -15,13 +16,14 @@ import VideoTable from './components/VideoTable.vue';
import CopyVideoModal from './CopyVideoModal.vue';
import DetailVideoModal from './DetailVideoModal.vue';
const detailVideoId = ref<string>("");
const copyVideoId = ref<string>("");
const detailVideoId = ref<string>('');
const copyVideoId = ref<string>('');
const uiState = useUIState();
const { addFiles, startQueue } = useUploadQueue();
const toast = useAppToast();
const router = useRouter();
const { t } = useI18n();
const videos = ref<ModelVideo[]>([]);
const loading = ref(true);
const error = ref<string | null>(null);
@@ -35,12 +37,12 @@ const limit = ref(10);
const total = ref(0);
// Filters
const statusOptions = [
{ label: 'All Status', value: 'all' },
{ label: 'Ready', value: 'ready' },
{ label: 'Processing', value: 'processing' },
{ label: 'Failed', value: 'failed' },
];
const statusOptions = computed(() => [
{ label: t('video.filters.allStatus'), value: 'all' },
{ label: t('video.filters.ready'), value: 'ready' },
{ label: t('video.filters.processing'), value: 'processing' },
{ label: t('video.filters.failed'), value: 'failed' },
]);
const fetchVideos = async () => {
loading.value = true;
@@ -91,7 +93,7 @@ const handlePageChange = (newPage: number) => {
const selectedVideos = ref<ModelVideo[]>([]);
const deleteSelectedVideos = async () => {
if (!selectedVideos.value.length || !confirm(`Delete ${selectedVideos.value.length} videos?`)) return;
if (!selectedVideos.value.length || !confirm(t('video.page.deleteSelectedConfirm', { count: selectedVideos.value.length }))) return;
try {
// Mock delete
@@ -100,12 +102,12 @@ const deleteSelectedVideos = async () => {
selectedVideos.value = [];
// In real app: await client.videos.bulkDelete(...) or loop
} catch (err) {
console.error("Failed to delete videos", err);
console.error('Failed to delete videos', err);
}
};
const deleteVideo = async (videoId?: string) => {
if (!videoId || !confirm('Are you sure you want to delete this video?')) return;
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
try {
videos.value = videos.value.filter(v => v.id !== videoId);
@@ -132,11 +134,11 @@ watch([searchQuery, selectedStatus, limit, page], () => {
fetchVideos();
});
const editVideo = (videoId?: string) => {
detailVideoId.value = videoId || "";
detailVideoId.value = videoId || '';
};
const copyVideo = (videoId?: string) => {
copyVideoId.value = videoId || "";
copyVideoId.value = videoId || '';
};
// ── Drag & drop upload ──────────────────────────────────────────────────
@@ -196,8 +198,10 @@ const onWindowDrop = (e: DragEvent) => {
if (result.duplicates > 0) {
toast.add({
severity: 'warn',
summary: 'Duplicate files skipped',
detail: `${result.duplicates} file${result.duplicates > 1 ? 's are' : ' is'} already in the queue.`,
summary: t('video.page.duplicateSummary'),
detail: result.duplicates > 1
? t('video.page.duplicateDetailOther', { count: result.duplicates })
: t('video.page.duplicateDetailOne', { count: result.duplicates }),
life: 4000,
});
}
@@ -221,12 +225,12 @@ onUnmounted(() => {
<template>
<div>
<PageHeader title="My Videos" description="Manage and organize your video library" :breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Videos' }
<PageHeader :title="t('video.page.title')" :description="t('video.page.description')" :breadcrumbs="[
{ label: t('pageHeader.dashboard'), to: '/' },
{ label: t('nav.videos') }
]" :actions="[
{
label: 'Upload Video',
label: t('video.page.uploadAction'),
icon: iconHoist,
variant: 'primary',
onClick: () => uiState.toggleUploadDialog()
@@ -245,23 +249,24 @@ onUnmounted(() => {
<p class="text-red-700 font-medium">{{ error }}</p>
<button @click="fetchVideos"
class="mt-4 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors">
Try Again
{{ t('video.page.retry') }}
</button>
</div>
<!-- Empty State -->
<EmptyState v-else-if="videos.length === 0 && !loading" title="No videos found"
description="You haven't uploaded any videos yet. Start by uploading your first video!"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" actionLabel="Upload Video"
<EmptyState v-else-if="videos.length === 0 && !loading" :title="t('video.page.emptyTitle')"
:description="t('video.page.emptyDescription')"
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('video.page.emptyAction')"
:onAction="() => router.push('/upload')" />
<!-- Grid View -->
<!-- <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" /> -->
<!-- Table View -->
<VideoTable v-else :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" @edit="editVideo" @copy="copyVideo" />
<VideoTable v-else :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo"
@edit="editVideo" @copy="copyVideo" />
</Transition>
<DetailVideoModal :videoId="detailVideoId" @close="detailVideoId = ''"/>
<CopyVideoModal :videoId="copyVideoId" @close="copyVideoId = ''"/>
<DetailVideoModal :videoId="detailVideoId" @close="detailVideoId = ''" />
<CopyVideoModal :videoId="copyVideoId" @close="copyVideoId = ''" />
<!-- Global drag & drop overlay -->
<ClientOnly>
@@ -281,12 +286,11 @@ onUnmounted(() => {
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
</div>
<p class="text-lg font-semibold text-primary">Drop to upload</p>
<p class="text-sm text-primary/70">Files will be added to the upload queue</p>
<p class="text-lg font-semibold text-primary">{{ t('video.page.uploadDropTitle') }}</p>
<p class="text-sm text-primary/70">{{ t('video.page.uploadDropSubtitle') }}</p>
</div>
</div>
</Teleport>
</ClientOnly>
</div>
</template>

View File

@@ -36,16 +36,17 @@
</template>
<script setup lang="ts">
import type { DefineComponent } from "vue";
import ArrowDownTray from "@/components/icons/ArrowDownTray.vue";
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 { DefineComponent } from 'vue';
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
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 { useAppToast } from "@/composables/useAppToast";
import { computed, nextTick, ref, shallowRef } from "vue";
import type { RouteLocationRaw } from "vue-router";
import { useAppToast } from '@/composables/useAppToast';
import { computed, nextTick, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import type { RouteLocationRaw } from 'vue-router';
const props = defineProps<{
video: ModelVideo
@@ -60,6 +61,7 @@ const isOpen = ref(false);
const containerRef = ref<HTMLElement>();
const menuRef = ref<HTMLElement>();
const menuStyle = ref<Record<string, string>>({});
const { t } = useI18n();
const videoUrl = computed(() => {
return `${window.location.origin}/videos/${props.video.id}`;
@@ -88,15 +90,15 @@ const handleCopyLink = async () => {
await navigator.clipboard.writeText(videoUrl.value);
toast.add({
severity: 'success',
summary: 'Thành công',
detail: 'Đã sao chép link video',
summary: t('video.cardPopover.toast.copySuccessSummary'),
detail: t('video.cardPopover.toast.copySuccessDetail'),
life: 3000
});
} catch {
toast.add({
severity: 'error',
summary: 'Lỗi',
detail: 'Không thể sao chép link',
summary: t('video.cardPopover.toast.copyErrorSummary'),
detail: t('video.cardPopover.toast.copyErrorDetail'),
life: 3000
});
}
@@ -113,15 +115,15 @@ const handleDownload = () => {
toast.add({
severity: 'success',
summary: 'Thành công',
detail: 'Đang tải xuống video...',
summary: t('video.cardPopover.toast.downloadSuccessSummary'),
detail: t('video.cardPopover.toast.downloadSuccessDetail'),
life: 3000
});
} else {
toast.add({
severity: 'error',
summary: 'Lỗi',
detail: 'Không tìm thấy file video',
summary: t('video.cardPopover.toast.downloadErrorSummary'),
detail: t('video.cardPopover.toast.downloadErrorDetail'),
life: 3000
});
}
@@ -141,14 +143,14 @@ interface CustomMenuItem {
command?: () => void;
}
const items = shallowRef<CustomMenuItem[]>([
const items = computed<CustomMenuItem[]>(() => [
{
label: 'Tải xuống',
label: t('video.cardPopover.download'),
icon: ArrowDownTray,
command: handleDownload
},
{
label: 'Sao chép link',
label: t('video.cardPopover.copyLink'),
icon: LinkIcon,
command: handleCopyLink
},
@@ -156,12 +158,12 @@ const items = shallowRef<CustomMenuItem[]>([
separator: true
},
{
label: 'Chỉnh sửa',
label: t('video.cardPopover.edit'),
icon: PencilIcon,
route: { name: 'video-detail', params: { id: props.video.id } }
},
{
label: 'Xóa',
label: t('video.cardPopover.delete'),
icon: TrashIcon,
iconClass: 'text-red-500',
labelClass: 'text-red-500',

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
defineProps<{
title: string;
description: string;
@@ -11,43 +13,45 @@ const emit = defineEmits<{
save: [];
toggleEdit: [];
}>();
const { t } = useI18n();
</script>
<template>
<div class="mb-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Title</label>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('video.detailModal.titleLabel') }}</label>
<input :value="title" type="text"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Enter video title"
:placeholder="t('video.detailModal.titlePlaceholder')"
@input="$emit('update:title', ($event.target as HTMLInputElement).value)">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Description</label>
<label class="block text-sm font-medium text-gray-700 mb-1">{{ t('video.detailModal.descriptionLabel') }}</label>
<textarea :value="description" rows="3"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="Enter video description"
:placeholder="t('video.detailModal.descriptionPlaceholder')"
@input="$emit('update:description', ($event.target as HTMLTextAreaElement).value)"></textarea>
</div>
<div class="float-right flex gap-2">
<AppButton size="sm"
title="Save changes" :disabled="saving" @click="$emit('save')">
:title="t('video.detailModal.saveChanges')" :disabled="saving" @click="$emit('save')">
<svg v-if="!saving" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span v-if="saving"
class="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin"></span>
<span class="hidden sm:inline">{{ saving ? 'Saving...' : 'Save' }}</span>
<span class="hidden sm:inline">{{ saving ? t('video.detailPage.saving') : t('common.save') }}</span>
</AppButton>
<!-- Cancel Button (Edit Mode) -->
<AppButton variant="danger" size="sm" title="Cancel editing"
<AppButton variant="danger" size="sm" :title="t('video.detailPage.cancelEditTitle')"
@click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12">
</path>
</svg>
<span class="hidden sm:inline">Cancel</span>
<span class="hidden sm:inline">{{ t('common.cancel') }}</span>
</AppButton>
</div>
</div>

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { getStatusSeverity } from '@/lib/utils';
import { formatBytes, getStatusSeverity } from '@/lib/utils';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
video: ModelVideo;
@@ -12,11 +14,11 @@ const emit = defineEmits<{
delete: [];
}>();
const { t, locale } = useI18n();
const formatFileSize = (bytes?: number): string => {
if (!bytes) return '-';
const mb = bytes / (1024 * 1024);
if (mb < 1) return `${(bytes / 1024).toFixed(2)} KB`;
return `${mb.toFixed(2)} MB`;
return formatBytes(bytes);
};
const formatDuration = (seconds?: number): string => {
@@ -33,7 +35,7 @@ const formatDuration = (seconds?: number): string => {
const formatDate = (dateStr?: string): string => {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleString('en-US', {
return date.toLocaleString(locale.value === 'vi' ? 'vi-VN' : 'en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
@@ -50,6 +52,19 @@ const severityClasses: Record<string, string> = {
danger: 'bg-red-100 text-red-800',
secondary: 'bg-gray-100 text-gray-800',
};
const statusLabel = computed(() => {
switch (props.video.status) {
case 'ready':
return t('video.filters.ready');
case 'processing':
return t('video.filters.processing');
case 'failed':
return t('video.filters.failed');
default:
return props.video.status;
}
});
</script>
<template>
@@ -71,7 +86,7 @@ const severityClasses: Record<string, string> = {
<span
class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
{{ video.status }}
{{ statusLabel }}
</span>
</div>
</div>
@@ -79,31 +94,31 @@ const severityClasses: Record<string, string> = {
<!-- Action Buttons -->
<div class="flex items-center space-x-2">
<AppButton size="sm" variant="secondary"
title="Reload video" @click="$emit('reload')">
:title="t('video.detailPage.reloadTitle')" @click="$emit('reload')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15">
</path>
</svg>
<span class="hidden sm:inline">Reload</span>
<span class="hidden sm:inline">{{ t('video.detailPage.reloadButton') }}</span>
</AppButton>
<AppButton size="sm" variant="ghost"
title="Edit" @click="$emit('toggleEdit')">
:title="t('video.table.edit')" @click="$emit('toggleEdit')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z">
</path>
</svg>
<span class="hidden sm:inline">Edit</span>
<span class="hidden sm:inline">{{ t('video.table.edit') }}</span>
</AppButton>
<AppButton variant="danger" size="sm"
title="Delete" @click="$emit('delete')">
:title="t('video.table.delete')" @click="$emit('delete')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16">
</path>
</svg>
<span class="hidden sm:inline">Delete</span>
<span class="hidden sm:inline">{{ t('video.table.delete') }}</span>
</AppButton>
</div>
</div>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
video: ModelVideo;
@@ -10,18 +11,23 @@ const emit = defineEmits<{
copy: [text: string, label: string];
}>();
const { t } = useI18n();
const handleCopy = (text: string, label: string) => {
emit('copy', text, label);
};
const origin = computed(() => typeof window !== 'undefined' ? window.location.origin : '');
const videoInfos = computed(() => {
if (!props.video) return [];
const embedUrl = props.video ? `${window.location.origin}/embed/${props.video.id}` : '';
const embedUrl = `${origin.value}/embed/${props.video.id}`;
return [
{ label: 'Video ID', value: props.video.id },
{ label: 'Thumbnail URL', value: props.video.thumbnail },
{ label: 'Embed URL', value: embedUrl },
{ label: 'Iframe Code', value: embedUrl ? `<iframe src="${embedUrl}" title="${props.video.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
{ label: 'Share Link', value: props.video ? `${window.location.origin}/view/${props.video.id}` : '' },
{ label: t('video.detailPage.videoInfo.videoId'), value: props.video.id },
{ label: t('video.detailPage.videoInfo.thumbnailUrl'), value: props.video.thumbnail },
{ label: t('video.detailPage.videoInfo.embedUrl'), value: embedUrl },
{ label: t('video.detailPage.videoInfo.iframeCode'), value: embedUrl ? `<iframe src="${embedUrl}" title="${props.video.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
{ label: t('video.detailPage.videoInfo.shareLink'), value: `${origin.value}/view/${props.video.id}` },
];
});
</script>
@@ -29,7 +35,7 @@ const videoInfos = computed(() => {
<template>
<div class="">
<div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3>
<h3 class="text-lg font-medium text-gray-900 mb-4">{{ t('video.detailPage.detailsTitle') }}</h3>
<div class="flex flex-col gap-2">
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
<div>
@@ -41,7 +47,7 @@ const videoInfos = computed(() => {
:value="info.value || '-'">
<button v-if="info.value" @click="handleCopy(info.value, info.label)"
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
title="Copy value">
:title="t('video.detailPage.copyValueTitle')">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { useI18n } from 'vue-i18n';
defineProps<{
video: ModelVideo;
}>();
const { t } = useI18n();
</script>
<template>
@@ -14,7 +17,7 @@ defineProps<{
controls
class="w-full h-full object-contain"
:poster="video.thumbnail">
Your browser does not support the video tag.
{{ t('video.detailPage.videoTagFallback') }}
</video>
</div>
<div v-else class="w-full h-48 bg-gray-200 overflow-hidden flex-shrink-0">

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { useI18n } from 'vue-i18n';
defineProps<{
selectedVideos: ModelVideo[];
@@ -9,17 +10,19 @@ const emit = defineEmits<{
(e: 'delete'): void;
(e: 'clear'): void;
}>();
const { t } = useI18n();
</script>
<template>
<div v-if="selectedVideos.length > 0"
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 bg-white border border-gray-200 shadow-xl rounded-full px-6 py-3 flex items-center gap-4 animate-in fade-in slide-in-from-bottom-4 duration-300">
<span class="font-medium text-sm text-gray-700">{{ selectedVideos.length }} selected</span>
<span class="font-medium text-sm text-gray-700">{{ t('video.bulk.selected', { count: selectedVideos.length }) }}</span>
<div class="h-4 w-px bg-gray-200"></div>
<button @click="emit('delete')"
class="flex items-center gap-2 text-red-600 hover:text-red-700 font-medium text-sm transition-colors">
<span class="i-heroicons-trash w-4 h-4" />
Delete
{{ t('video.bulk.delete') }}
</button>
<button @click="emit('clear')" class="ml-2 text-gray-400 hover:text-gray-600">
<span class="i-heroicons-x-mark w-5 h-5" />

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const props = defineProps<{
searchQuery: string;
selectedStatus: string;
@@ -17,6 +19,7 @@ const emit = defineEmits<{
(e: 'search'): void;
}>();
const { t } = useI18n();
const pageCount = computed(() => Math.ceil(props.total / props.limit) || 1);
const first = computed(() => Math.min((props.page - 1) * props.limit + 1, props.total));
const last = computed(() => Math.min(props.page * props.limit, props.total));
@@ -35,7 +38,7 @@ const nextPage = () => {
<div class="flex flex-col md:flex-row gap-3 items-stretch md:items-center">
<!-- Search -->
<AppInput :model-value="searchQuery" @update:model-value="emit('update:searchQuery', $event as string)"
@enter="emit('search')" placeholder="Search videos..." class="flex-1">
@enter="emit('search')" :placeholder="t('video.filters.searchPlaceholder')" class="flex-1">
<template #prefix>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -56,18 +59,18 @@ const nextPage = () => {
<!-- Paginator -->
<div class="flex justify-end w-full gap-2 mt-3 mb-2">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ first }}&ndash;{{ last }} of {{ total }}
{{ t('video.filters.rangeOfTotal', { first, last, total }) }}
</span>
<div class="flex items-center gap-1">
<button class="p-1.5 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="prevPage" :disabled="page <= 1" aria-label="Previous page">
@click="prevPage" :disabled="page <= 1" :aria-label="t('video.filters.previousPageAria')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<button class="p-1.5 rounded-full hover:bg-gray-100 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
@click="nextPage" :disabled="page >= pageCount" aria-label="Next page">
@click="nextPage" :disabled="page >= pageCount" :aria-label="t('video.filters.nextPageAria')">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6" />

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import { formatDate, formatDuration, getStatusSeverity } from '@/lib/utils';
import { useI18n } from 'vue-i18n';
import CardPopover from './CardPopover.vue';
const props = defineProps<{
@@ -14,6 +15,8 @@ const emit = defineEmits<{
(e: 'delete', videoId: string): void;
}>();
const { t } = useI18n();
const severityClasses: Record<string, string> = {
success: 'bg-green-100 text-green-800',
info: 'bg-blue-100 text-blue-800',
@@ -90,7 +93,7 @@ const toggleSelection = (video: ModelVideo) => {
</button>
</div>
<p class="text-xs text-gray-500 mb-3 line-clamp-1 h-4">{{ video.description || 'No description' }}
<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) }}

View File

@@ -5,6 +5,7 @@ import PencilIcon from '@/components/icons/PencilIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import VideoIcon from '@/components/icons/VideoIcon.vue';
import { formatBytes, formatDate, getStatusSeverity } from '@/lib/utils';
import { useI18n } from 'vue-i18n';
const props = defineProps<{
videos: ModelVideo[];
@@ -19,6 +20,8 @@ const emit = defineEmits<{
(e: 'copy', videoId: string): void;
}>();
const { t } = useI18n();
const severityClasses: Record<string, string> = {
success: 'bg-green-100 text-green-800',
info: 'bg-blue-100 text-blue-800',
@@ -77,11 +80,11 @@ const isSelected = (video: ModelVideo) =>
<input type="checkbox" :checked="isAllSelected" @change="toggleAll"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary" />
</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Video</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Status</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Size</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Created</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">Actions</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.video') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.status') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.size') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.created') }}</th>
<th class="px-4 py-3 text-left text-sm font-medium text-gray-600">{{ t('video.table.actions') }}</th>
</tr>
</thead>
<tbody>
@@ -103,7 +106,7 @@ const isSelected = (video: ModelVideo) =>
</div>
<div class="min-w-0 flex-1">
<p class="font-medium text-gray-900 truncate">{{ data.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ data.description || 'No description' }}</p>
<p class="text-sm text-gray-500 truncate">{{ data.description || t('video.table.noDescription') }}</p>
</div>
</div>
</td>
@@ -122,15 +125,15 @@ const isSelected = (video: ModelVideo) =>
<td class="px-4 py-3">
<div class="flex items-center gap-0.5">
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-gray-700 transition-colors"
title="Copy link" @click="emit('copy', data.id!)">
:title="t('video.table.copyLink')" @click="emit('copy', data.id!)">
<LinkIcon class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-gray-100 text-gray-500 hover:text-primary transition-colors"
title="Edit" @click="emit('edit', data.id!)">
:title="t('video.table.edit')" @click="emit('edit', data.id!)">
<PencilIcon class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-red-50 text-gray-500 hover:text-red-500 transition-colors"
title="Delete" @click="emit('delete', data.id!)">
:title="t('video.table.delete')" @click="emit('delete', data.id!)">
<TrashIcon class="w-4 h-4" />
</button>
</div>

View File

@@ -4,17 +4,40 @@ import { streamText } from 'hono/streaming';
import { renderToWebStream } from 'vue/server-renderer';
import { createApp } from '@/main';
import { defaultLocale, localeCookieKey } from '@/i18n/constants';
import { normalizeLocale, resolveLocaleFromAcceptLanguage } from '@/i18n';
import { useAuthStore } from '@/stores/auth';
import { buildBootstrapScript } from '@/lib/manifest';
import { htmlEscape } from '@/server/utils/htmlEscape';
import type { Hono } from 'hono';
const parseCookie = (cookieHeader: string | undefined, key: string): string | undefined => {
if (!cookieHeader) return undefined;
const segments = cookieHeader.split(';');
for (const segment of segments) {
const [rawKey, ...rest] = segment.trim().split('=');
if (rawKey !== key) continue;
return decodeURIComponent(rest.join('='));
}
return undefined;
};
const resolveLocaleFromAuthUser = (authUser: unknown): string | undefined => {
if (!authUser || typeof authUser !== 'object') return undefined;
const maybeLanguage = (authUser as any).language ?? (authUser as any).locale;
return typeof maybeLanguage === 'string' ? maybeLanguage : undefined;
};
export function registerSSRRoutes(app: Hono) {
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app: vueApp, router, head, pinia, bodyClass, queryCache } = createApp();
const cookieLocaleRaw = parseCookie(c.req.header('cookie'), localeCookieKey);
const acceptLocale = resolveLocaleFromAcceptLanguage(c.req.header('accept-language'));
const bootstrapLocale = normalizeLocale(cookieLocaleRaw ?? acceptLocale ?? defaultLocale);
const { app: vueApp, router, head, pinia, bodyClass, queryCache, i18n } = createApp(bootstrapLocale);
vueApp.provide("honoContext", c);
@@ -22,6 +45,10 @@ export function registerSSRRoutes(app: Hono) {
auth.$reset();
await auth.init();
const userPreferredLocale = resolveLocaleFromAuthUser(auth.user);
const resolvedLocale = normalizeLocale(userPreferredLocale ?? cookieLocaleRaw ?? acceptLocale ?? defaultLocale);
i18n.global.locale.value = resolvedLocale;
await router.push(url.pathname);
await router.isReady();
@@ -33,7 +60,7 @@ export function registerSSRRoutes(app: Hono) {
const appStream = renderToWebStream(vueApp, ctx);
// HTML Head
await stream.write("<!DOCTYPE html><html lang='en'><head>");
await stream.write(`<!DOCTYPE html><html lang='${resolvedLocale}'><head>`);
await stream.write("<base href='" + url.origin + "'/>");
// SSR Head tags
@@ -62,7 +89,8 @@ export function registerSSRRoutes(app: Hono) {
// Inject state
Object.assign(ctx, {
$p: pinia.state.value,
$colada: serializeQueryCache(queryCache)
$colada: serializeQueryCache(queryCache),
$locale: resolvedLocale,
});
// App data script

View File

@@ -1,12 +1,36 @@
import { defineStore } from 'pinia';
import { useRouter } from 'vue-router';
import { ref } from 'vue';
import { ref, watch } from 'vue';
import { client, ResponseResponse, type ModelUser } from '@/api/client';
import { defaultLocale, localeCookieKey, type SupportedLocale } from '@/i18n/constants';
import { getActiveI18n, normalizeLocale } from '@/i18n';
import { TinyMqttClient } from '@/lib/liteMqtt';
type ProfileUpdatePayload = { username?: string; email?: string; language?: string; locale?: string };
const cookieMaxAge = 60 * 60 * 24 * 365;
const writeLocaleCookie = (locale: SupportedLocale) => {
if (typeof document === 'undefined') return;
document.cookie = `${localeCookieKey}=${encodeURIComponent(locale)}; path=/; max-age=${cookieMaxAge}; samesite=lax`;
};
const resolveUserLocale = (target: Partial<ModelUser> | null | undefined): SupportedLocale => {
const userLocale = (target as any)?.language ?? (target as any)?.locale;
return normalizeLocale(typeof userLocale === 'string' ? userLocale : defaultLocale);
};
const applyRuntimeLocale = (locale: SupportedLocale) => {
const i18n = getActiveI18n();
if (!i18n) return;
i18n.global.locale.value = locale;
};
export const useAuthStore = defineStore('auth', () => {
const user = ref<ModelUser | null>(null);
const router = useRouter();
const t = (key: string, params?: Record<string, unknown>) =>
getActiveI18n()?.global.t(key, params) ?? key;
const loading = ref(false);
const error = ref<string | null>(null);
const initialized = ref(false);
@@ -29,6 +53,13 @@ export const useAuthStore = defineStore('auth', () => {
client = undefined;
}
}, { deep: true });
watch(user, (newUser) => {
if (import.meta.env.SSR) return;
const locale = resolveUserLocale(newUser);
applyRuntimeLocale(locale);
writeLocaleCookie(locale);
}, { deep: true, immediate: true });
// Initial check for session could go here if there was a /me endpoint or token check
async function init() {
if (initialized.value) return;
@@ -39,6 +70,8 @@ export const useAuthStore = defineStore('auth', () => {
}).then(r => r.json()).then(r => {
if (r.data) {
user.value = r.data.user as ModelUser;
const resolvedLocale = resolveUserLocale(user.value);
applyRuntimeLocale(resolvedLocale);
}
}).catch(() => { }).finally(() => {
initialized.value = true;
@@ -75,13 +108,16 @@ export const useAuthStore = defineStore('auth', () => {
console.log("body", body);
if (body && body.data) {
user.value = body.data.user;
const resolvedLocale = resolveUserLocale(user.value);
applyRuntimeLocale(resolvedLocale);
writeLocaleCookie(resolvedLocale);
router.push('/');
} else {
throw new Error('Login failed: No user data received');
throw new Error(t('auth.errors.loginNoUserData'));
}
} catch (e: any) {
console.error(e);
error.value = 'Login failed: ' + (e.message || 'Unknown error');
error.value = t('auth.errors.loginFailed', { error: e.message || t('auth.errors.unknown') });
throw e;
} finally {
loading.value = false;
@@ -112,18 +148,18 @@ export const useAuthStore = defineStore('auth', () => {
// Usually register returns success, user must login.
router.push('/login');
} else {
throw new Error(body.message || 'Registration failed');
throw new Error(body.message || t('auth.errors.registrationFailedFallback'));
}
} catch (e: any) {
console.error(e);
error.value = 'Registration failed: ' + (e.message || 'Unknown error');
error.value = t('auth.errors.registrationFailed', { error: e.message || t('auth.errors.unknown') });
throw e;
} finally {
loading.value = false;
}
}
async function updateProfile(data: { username?: string; email?: string }) {
async function updateProfile(data: ProfileUpdatePayload) {
loading.value = true;
error.value = null;
try {
@@ -139,18 +175,51 @@ export const useAuthStore = defineStore('auth', () => {
const body = response.data as any;
if (body && body.data) {
user.value = { ...user.value, ...body.data };
user.value = { ...(user.value ?? {}), ...body.data } as ModelUser;
}
return true;
} catch (e: any) {
console.error('Update profile error', e);
error.value = 'Failed to update profile: ' + (e.message || 'Unknown error');
error.value = t('auth.errors.updateProfileFailed', { error: e.message || t('auth.errors.unknown') });
throw e;
} finally {
loading.value = false;
}
}
async function setLanguage(locale: string) {
const normalizedLocale = normalizeLocale(locale);
const previousLocale = resolveUserLocale(user.value);
const previousUser = user.value ? { ...user.value } : null;
applyRuntimeLocale(normalizedLocale);
writeLocaleCookie(normalizedLocale);
if (user.value) {
user.value = {
...user.value,
language: normalizedLocale,
locale: normalizedLocale,
} as ModelUser;
}
if (!user.value?.id) {
return { ok: true as const, fallbackOnly: true as const };
}
try {
await updateProfile({ language: normalizedLocale, locale: normalizedLocale });
return { ok: true as const, fallbackOnly: false as const };
} catch (e) {
applyRuntimeLocale(previousLocale);
if (previousUser) {
user.value = previousUser as ModelUser;
}
writeLocaleCookie(normalizedLocale);
return { ok: false as const, fallbackOnly: true as const, error: e };
}
}
async function changePassword(currentPassword: string, newPassword: string) {
loading.value = true;
error.value = null;
@@ -167,7 +236,7 @@ export const useAuthStore = defineStore('auth', () => {
return true;
} catch (e: any) {
console.error('Change password error', e);
error.value = 'Failed to change password: ' + (e.message || 'Unknown error');
error.value = t('auth.errors.changePasswordFailed', { error: e.message || t('auth.errors.unknown') });
throw e;
} finally {
loading.value = false;
@@ -185,8 +254,10 @@ export const useAuthStore = defineStore('auth', () => {
register,
updateProfile,
changePassword,
setLanguage,
logout: async () => {
loading.value = true;
const localeBeforeLogout = resolveUserLocale(user.value);
try {
await client.auth.logoutCreate();
user.value = null;
@@ -196,6 +267,8 @@ export const useAuthStore = defineStore('auth', () => {
user.value = null;
router.push('/login');
} finally {
writeLocaleCookie(localeBeforeLogout);
applyRuntimeLocale(localeBeforeLogout);
loading.value = false;
}
},

View File

@@ -10,6 +10,9 @@ import ssrPlugin from "./ssrPlugin";
export default defineConfig((env) => {
// console.log("env:", env, import.meta.env);
return {
server: {
host: '0.0.0.0'
},
plugins: [
unocss(),
vue(),