done ui
This commit is contained in:
34
bun.lock
34
bun.lock
@@ -5,6 +5,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "holistream",
|
"name": "holistream",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hattip/adapter-node": "^0.0.49",
|
||||||
"@hono/node-server": "^1.19.11",
|
"@hono/node-server": "^1.19.11",
|
||||||
"@pinia/colada": "^0.21.7",
|
"@pinia/colada": "^0.21.7",
|
||||||
"@unhead/vue": "^2.1.10",
|
"@unhead/vue": "^2.1.10",
|
||||||
@@ -13,7 +14,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.5",
|
"hono": "^4.12.5",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.8.14",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"i18next-vue": "^5.4.0",
|
"i18next-vue": "^5.4.0",
|
||||||
"is-mobile": "^5.0.0",
|
"is-mobile": "^5.0.0",
|
||||||
@@ -170,6 +170,16 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||||
|
|
||||||
|
"@hattip/adapter-node": ["@hattip/adapter-node@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49", "@hattip/polyfills": "0.0.49", "@hattip/walk": "0.0.49" } }, "sha512-BE+Y8Q4U0YcH34FZUYU4DssGKOaZLbNL0zK57Z41UZp0m9kS79ZIolBmjjpPhTVpIlRY3Rs+uhXbVXKk7mUcJA=="],
|
||||||
|
|
||||||
|
"@hattip/core": ["@hattip/core@0.0.49", "", {}, "sha512-3/ZJtC17cv8m6Sph8+nw4exUp9yhEf2Shi7HK6AHSUSBtaaQXZ9rJBVxTfZj3PGNOR/P49UBXOym/52WYKFTJQ=="],
|
||||||
|
|
||||||
|
"@hattip/headers": ["@hattip/headers@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49" } }, "sha512-rrB2lEhTf0+MNVt5WdW184Ky706F1Ze9Aazn/R8c+/FMUYF9yjem2CgXp49csPt3dALsecrnAUOHFiV0LrrHXA=="],
|
||||||
|
|
||||||
|
"@hattip/polyfills": ["@hattip/polyfills@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49", "@whatwg-node/fetch": "^0.9.22", "node-fetch-native": "^1.6.4" } }, "sha512-5g7W5s6Gq+HDxwULGFQ861yAnEx3yd9V8GDwS96HBZ1nM1u93vN+KTuwXvNsV7Z3FJmCrD/pgU8WakvchclYuA=="],
|
||||||
|
|
||||||
|
"@hattip/walk": ["@hattip/walk@0.0.49", "", { "dependencies": { "@hattip/headers": "0.0.49", "cac": "^6.7.14", "mime-types": "^2.1.35" }, "bin": { "hattip-walk": "cli.js" } }, "sha512-AgJgKLooZyQnzMfoFg5Mo/aHM+HGBC9ExpXIjNqGimYTRgNbL/K7X5EM1kR2JY90BNKk9lo6Usq1T/nWFdT7TQ=="],
|
||||||
|
|
||||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||||
|
|
||||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||||
@@ -236,6 +246,8 @@
|
|||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
|
"@kamilkisiela/fast-url-parser": ["@kamilkisiela/fast-url-parser@1.1.4", "", {}, "sha512-gbkePEBupNydxCelHCESvFSFM8XPh1Zs/OAVRW/rKpEqPAl5PbOM90Si8mv9bvnR53uPD2s/FiRxdvSejpRJew=="],
|
||||||
|
|
||||||
"@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=="],
|
"@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-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.115.0", "", { "os": "android", "cpu": "arm" }, "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw=="],
|
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.115.0", "", { "os": "android", "cpu": "arm" }, "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw=="],
|
||||||
@@ -420,6 +432,10 @@
|
|||||||
|
|
||||||
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
|
||||||
|
|
||||||
|
"@whatwg-node/fetch": ["@whatwg-node/fetch@0.9.23", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.6.0", "urlpattern-polyfill": "^10.0.0" } }, "sha512-7xlqWel9JsmxahJnYVUj/LLxWcnA93DR4c9xlw3U814jWTiYalryiH1qToik1hOxweKKRLi4haXHM5ycRksPBA=="],
|
||||||
|
|
||||||
|
"@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.6.0", "", { "dependencies": { "@kamilkisiela/fast-url-parser": "^1.1.4", "busboy": "^1.6.0", "fast-querystring": "^1.1.1", "tslib": "^2.6.3" } }, "sha512-tcZAhrpx6oVlkEsRngeTEEE7I5/QdLjeEz4IlekabGaESP7+Dkm/6a9KcF1KdCBB7mO9PXtBkwCuTCt8+UPg8Q=="],
|
||||||
|
|
||||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
"ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
"ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
||||||
@@ -436,6 +452,8 @@
|
|||||||
|
|
||||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||||
|
|
||||||
|
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
||||||
|
|
||||||
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
|
||||||
|
|
||||||
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
|
||||||
@@ -488,6 +506,10 @@
|
|||||||
|
|
||||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||||
|
|
||||||
|
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
|
||||||
|
|
||||||
|
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||||
@@ -502,8 +524,6 @@
|
|||||||
|
|
||||||
"i18next": ["i18next@25.8.14", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="],
|
"i18next": ["i18next@25.8.14", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="],
|
||||||
|
|
||||||
"i18next-browser-languagedetector": ["i18next-browser-languagedetector@8.2.1", "", { "dependencies": { "@babel/runtime": "^7.23.2" } }, "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw=="],
|
|
||||||
|
|
||||||
"i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="],
|
"i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="],
|
||||||
|
|
||||||
"i18next-vue": ["i18next-vue@5.4.0", "", { "peerDependencies": { "i18next": ">=23", "vue": "^3.4.38" } }, "sha512-GDj0Xvmis5Xgcvo9gMBJMgJCtewYMLZP6gAEPDDGCMjA+QeB4uS4qUf1MK79mkz/FukhaJdC+nlj0y1qk6NO2Q=="],
|
"i18next-vue": ["i18next-vue@5.4.0", "", { "peerDependencies": { "i18next": ">=23", "vue": "^3.4.38" } }, "sha512-GDj0Xvmis5Xgcvo9gMBJMgJCtewYMLZP6gAEPDDGCMjA+QeB4uS4qUf1MK79mkz/FukhaJdC+nlj0y1qk6NO2Q=="],
|
||||||
@@ -558,6 +578,10 @@
|
|||||||
|
|
||||||
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
|
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
|
||||||
|
|
||||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||||
@@ -626,6 +650,8 @@
|
|||||||
|
|
||||||
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
|
||||||
|
|
||||||
|
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||||
|
|
||||||
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
|
||||||
|
|
||||||
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
|
||||||
@@ -674,6 +700,8 @@
|
|||||||
|
|
||||||
"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=="],
|
"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=="],
|
||||||
|
|
||||||
|
"urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
|
||||||
|
|
||||||
"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": ["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=="],
|
"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=="],
|
||||||
|
|||||||
2
components.d.ts
vendored
2
components.d.ts
vendored
@@ -24,6 +24,7 @@ declare module 'vue' {
|
|||||||
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||||
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||||
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||||
|
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
@@ -103,6 +104,7 @@ declare global {
|
|||||||
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||||
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||||
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||||
|
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"tail": "wrangler tail"
|
"tail": "wrangler tail"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hattip/adapter-node": "^0.0.49",
|
||||||
"@hono/node-server": "^1.19.11",
|
"@hono/node-server": "^1.19.11",
|
||||||
"@pinia/colada": "^0.21.7",
|
"@pinia/colada": "^0.21.7",
|
||||||
"@unhead/vue": "^2.1.10",
|
"@unhead/vue": "^2.1.10",
|
||||||
@@ -18,7 +19,6 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.5",
|
"hono": "^4.12.5",
|
||||||
"i18next": "^25.8.14",
|
"i18next": "^25.8.14",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
|
||||||
"i18next-http-backend": "^3.0.2",
|
"i18next-http-backend": "^3.0.2",
|
||||||
"i18next-vue": "^5.4.0",
|
"i18next-vue": "^5.4.0",
|
||||||
"is-mobile": "^5.0.0",
|
"is-mobile": "^5.0.0",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"status": "Status",
|
"status": "Status",
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"selected": "{count} selected",
|
"selected": "{{count}} selected",
|
||||||
"copy": "Copy"
|
"copy": "Copy"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -211,6 +211,10 @@
|
|||||||
"chromecast": {
|
"chromecast": {
|
||||||
"title": "Chromecast",
|
"title": "Chromecast",
|
||||||
"description": "Allow casting to Chromecast devices"
|
"description": "Allow casting to Chromecast devices"
|
||||||
|
},
|
||||||
|
"encrytion_m3u8": {
|
||||||
|
"title": "HLS Encryption (m3u8)",
|
||||||
|
"description": "Enable encryption for HLS streams (Anti-download, VIP only)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -240,10 +244,12 @@
|
|||||||
"clearDataReject": "Cancel"
|
"clearDataReject": "Cancel"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"deleteAccountSummary": "Account deletion requested",
|
"deleteAccountSummary": "Account deleted",
|
||||||
"deleteAccountDetail": "Your account deletion request has been submitted.",
|
"deleteAccountDetail": "Your account and associated data have been permanently deleted.",
|
||||||
"clearDataSummary": "Data cleared",
|
"clearDataSummary": "Data cleared",
|
||||||
"clearDataDetail": "All your data has been permanently deleted."
|
"clearDataDetail": "All your data has been permanently deleted.",
|
||||||
|
"failedSummary": "Action failed",
|
||||||
|
"failedDetail": "Failed to complete the requested action."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainsDns": {
|
"domainsDns": {
|
||||||
@@ -282,13 +288,18 @@
|
|||||||
"removedSummary": "Domain Removed",
|
"removedSummary": "Domain Removed",
|
||||||
"removedDetail": "{domain} has been removed from your whitelist.",
|
"removedDetail": "{domain} has been removed from your whitelist.",
|
||||||
"copiedSummary": "Copied",
|
"copiedSummary": "Copied",
|
||||||
"copiedDetail": "Embed code copied to clipboard."
|
"copiedDetail": "Embed code copied to clipboard.",
|
||||||
|
"failedSummary": "Action failed",
|
||||||
|
"failedDetail": "Failed to load or update domains."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adsVast": {
|
"adsVast": {
|
||||||
"createTemplate": "Create Template",
|
"createTemplate": "Create Template",
|
||||||
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
|
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
|
||||||
"createdOn": "Created {date}",
|
"readOnlyTitle": "Upgrade required",
|
||||||
|
"readOnlyMessage": "Ads & VAST is read-only on the free plan. Upgrade your plan to create, edit, delete, enable, or set default templates.",
|
||||||
|
"defaultBadge": "Default",
|
||||||
|
"createdOn": "Created {{date}}",
|
||||||
"emptyTitle": "No VAST templates yet",
|
"emptyTitle": "No VAST templates yet",
|
||||||
"emptySubtitle": "Create a template to start monetizing your videos",
|
"emptySubtitle": "Create a template to start monetizing your videos",
|
||||||
"formats": {
|
"formats": {
|
||||||
@@ -300,6 +311,10 @@
|
|||||||
"enabled": "enabled",
|
"enabled": "enabled",
|
||||||
"disabled": "disabled"
|
"disabled": "disabled"
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"default": "Default",
|
||||||
|
"setDefault": "Set default"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"template": "Template",
|
"template": "Template",
|
||||||
"format": "Format",
|
"format": "Format",
|
||||||
@@ -315,6 +330,10 @@
|
|||||||
"adFormat": "Ad Format",
|
"adFormat": "Ad Format",
|
||||||
"adInterval": "Ad Interval (seconds)",
|
"adInterval": "Ad Interval (seconds)",
|
||||||
"adIntervalPlaceholder": "30",
|
"adIntervalPlaceholder": "30",
|
||||||
|
"defaultLabel": "Default template",
|
||||||
|
"defaultCheckbox": "Use this template by default for new videos",
|
||||||
|
"defaultHint": "When enabled, newly created videos automatically use this active template.",
|
||||||
|
"defaultDisabledHint": "Enable this template before setting it as default.",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"create": "Create"
|
"create": "Create"
|
||||||
},
|
},
|
||||||
@@ -339,11 +358,17 @@
|
|||||||
"createdDetail": "VAST template has been created.",
|
"createdDetail": "VAST template has been created.",
|
||||||
"enabledSummary": "Template Enabled",
|
"enabledSummary": "Template Enabled",
|
||||||
"disabledSummary": "Template Disabled",
|
"disabledSummary": "Template Disabled",
|
||||||
|
"defaultUpdatedSummary": "Default Updated",
|
||||||
|
"defaultUpdatedDetail": "{name} is now the default template for new videos.",
|
||||||
|
"upgradeRequiredSummary": "Upgrade required",
|
||||||
|
"upgradeRequiredDetail": "Upgrade your plan to manage Ads & VAST.",
|
||||||
"toggleDetail": "{name} has been {state}.",
|
"toggleDetail": "{name} has been {state}.",
|
||||||
"deletedSummary": "Template Deleted",
|
"deletedSummary": "Template Deleted",
|
||||||
"deletedDetail": "VAST template has been removed.",
|
"deletedDetail": "VAST template has been removed.",
|
||||||
"copiedSummary": "Copied",
|
"copiedSummary": "Copied",
|
||||||
"copiedDetail": "URL copied to clipboard."
|
"copiedDetail": "URL copied to clipboard.",
|
||||||
|
"failedSummary": "Action failed",
|
||||||
|
"failedDetail": "Failed to load or update VAST templates."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -353,7 +378,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email Address",
|
"email": "Email Address",
|
||||||
"storageUsage": "Storage Usage",
|
"storageUsage": "Storage Usage",
|
||||||
"storageUsedOfLimit": "{used} of {limit} used",
|
"storageUsedOfLimit": "{{used}} of {{limit}} used",
|
||||||
"editProfile": "Edit Profile",
|
"editProfile": "Edit Profile",
|
||||||
"changePassword": "Change Password"
|
"changePassword": "Change Password"
|
||||||
},
|
},
|
||||||
@@ -373,26 +398,51 @@
|
|||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"walletBalance": "Wallet Balance",
|
"walletBalance": "Wallet Balance",
|
||||||
"currentBalance": "Current balance: {balance}",
|
"currentBalance": "Current balance: {{balance}}",
|
||||||
"topUp": "Top Up",
|
"topUp": "Top Up",
|
||||||
"availablePlans": "Available Plans",
|
"availablePlans": "Available Plans",
|
||||||
"availablePlansHint": "Choose the plan that best fits your needs",
|
"availablePlansHint": "Choose the plan that best fits your needs",
|
||||||
"planStorage": "{storage} Storage",
|
"planStorage": "{{storage}} Storage",
|
||||||
"planDuration": "{duration} Max Duration",
|
"planDuration": "{{duration}} Max Duration",
|
||||||
"planUploads": "{count} Uploads / day",
|
"planUploads": "{{count}} Uploads / day",
|
||||||
"currentPlan": "Current Plan",
|
"currentPlan": "Current Plan",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"upgrade": "Upgrade",
|
"upgrade": "Upgrade",
|
||||||
"storage": "Storage",
|
"storage": "Storage",
|
||||||
"storageUsedOfLimit": "{used} of {limit} used",
|
"storageUsedOfLimit": "{{used}} of {{limit}} used",
|
||||||
"monthlyUploads": "Monthly Uploads",
|
"totalVideos": "Total videos",
|
||||||
"uploadsUsedOfLimit": "{used} of {limit} uploads",
|
"totalVideosUsedOfLimit": "{{used}} of {{limit}} videos",
|
||||||
"paymentHistory": "Payment History",
|
"paymentHistory": "Payment History",
|
||||||
"paymentHistorySubtitle": "Your past payments and invoices",
|
"paymentHistorySubtitle": "Your past payments and invoices",
|
||||||
"noPaymentHistory": "No payment history found.",
|
"noPaymentHistory": "No payment history found.",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"durationMinutes": "{minutes} mins",
|
"durationMinutes": "{{minutes}} mins",
|
||||||
"unknownPlan": "Unknown",
|
"unknownPlan": "Unknown",
|
||||||
|
"walletTopup": "Wallet Top-up",
|
||||||
|
"termOption": "{{months}} month",
|
||||||
|
"termOption_other": "{{months}} months",
|
||||||
|
"paymentMethod": {
|
||||||
|
"wallet": "Wallet balance",
|
||||||
|
"topup": "Top up and pay"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"activeTitle": "Plan active",
|
||||||
|
"activeDescription": " {{plan}} is active until {{date}}",
|
||||||
|
"expiringTitle": "Expiring soon",
|
||||||
|
"expiringDescription": " {{plan}} expires on {{date}}",
|
||||||
|
"expiredTitle": "Plan expired",
|
||||||
|
"expiredDescription": "Your last subscription ended on {{date}}",
|
||||||
|
"freeTitle": "Free access",
|
||||||
|
"freeDescription": "You are currently using the free plan."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"validUntil": "Valid until {{date}}"
|
||||||
|
},
|
||||||
|
"cycle": {
|
||||||
|
"MONTHLY": "Monthly",
|
||||||
|
"QUARTERLY": "Quarterly",
|
||||||
|
"YEARLY": "Yearly"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"date": "Date",
|
"date": "Date",
|
||||||
"amount": "Amount",
|
"amount": "Amount",
|
||||||
@@ -405,6 +455,31 @@
|
|||||||
"failed": "Failed",
|
"failed": "Failed",
|
||||||
"pending": "Pending"
|
"pending": "Pending"
|
||||||
},
|
},
|
||||||
|
"upgradeDialog": {
|
||||||
|
"title": "Upgrade or renew plan",
|
||||||
|
"selectedPlan": "Selected plan",
|
||||||
|
"basePrice": "Base monthly price",
|
||||||
|
"perMonthBase": "Used as the monthly base for the selected term",
|
||||||
|
"termTitle": "Billing term",
|
||||||
|
"termHint": "Choose how long you want to activate or extend this plan.",
|
||||||
|
"totalLabel": "Total",
|
||||||
|
"walletBalanceLabel": "Wallet balance",
|
||||||
|
"shortfallLabel": "Shortfall",
|
||||||
|
"paymentMethodTitle": "How do you want to pay?",
|
||||||
|
"paymentMethodHint": "If your wallet is short, you can top up the difference and complete the plan purchase in one flow.",
|
||||||
|
"walletOptionDescription": "Try using your current wallet balance first.",
|
||||||
|
"topupOptionDescription": "Top up at least {{shortfall}} to complete the purchase.",
|
||||||
|
"walletCoveredHint": "Your wallet balance already covers this purchase.",
|
||||||
|
"walletInsufficientHint": "Your wallet is short by {{shortfall}}. Switch to the top-up option to complete the purchase.",
|
||||||
|
"topupAmountLabel": "Top-up amount",
|
||||||
|
"topupAmountPlaceholder": "Enter top-up amount",
|
||||||
|
"topupAmountHint": "Any amount above {{shortfall}} will stay in your wallet after the upgrade.",
|
||||||
|
"payWithWallet": "Pay with wallet",
|
||||||
|
"topupAndUpgrade": "Top up and upgrade",
|
||||||
|
"choosePlan": "Choose plan",
|
||||||
|
"selecting": "Opening...",
|
||||||
|
"footerHint": "The new term will be added from your current expiry if your subscription is still active."
|
||||||
|
},
|
||||||
"topupDialog": {
|
"topupDialog": {
|
||||||
"title": "Top Up Wallet",
|
"title": "Top Up Wallet",
|
||||||
"subtitle": "Select an amount or enter a custom amount to add to your wallet.",
|
"subtitle": "Select an amount or enter a custom amount to add to your wallet.",
|
||||||
@@ -415,17 +490,19 @@
|
|||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"subscriptionSuccessSummary": "Subscription Successful",
|
"subscriptionSuccessSummary": "Subscription Successful",
|
||||||
"subscriptionSuccessDetail": "Successfully subscribed to {plan}",
|
"subscriptionSuccessDetail": "Successfully activated {{plan}} for {{term}}",
|
||||||
"subscriptionFailedSummary": "Subscription Failed",
|
"subscriptionFailedSummary": "Subscription Failed",
|
||||||
"subscriptionFailedDetail": "Failed to subscribe",
|
"subscriptionFailedDetail": "Failed to subscribe",
|
||||||
"topupSuccessSummary": "Top-up Successful",
|
"topupSuccessSummary": "Top-up Successful",
|
||||||
"topupSuccessDetail": "{amount} has been added to your wallet.",
|
"topupSuccessDetail": "{{amount}} has been added to your wallet.",
|
||||||
"topupFailedSummary": "Top-up Failed",
|
"topupFailedSummary": "Top-up Failed",
|
||||||
"topupFailedDetail": "Failed to process top-up.",
|
"topupFailedDetail": "Failed to process top-up.",
|
||||||
"downloadingSummary": "Downloading",
|
"downloadingSummary": "Downloading",
|
||||||
"downloadingDetail": "Downloading invoice #{invoiceId}...",
|
"downloadingDetail": "Downloading invoice #{invoiceId}...",
|
||||||
"downloadedSummary": "Downloaded",
|
"downloadedSummary": "Downloaded",
|
||||||
"downloadedDetail": "Invoice #{invoiceId} downloaded successfully"
|
"downloadedDetail": "Invoice #{invoiceId} downloaded successfully",
|
||||||
|
"downloadFailedSummary": "Download Failed",
|
||||||
|
"downloadFailedDetail": "Failed to download invoice."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityConnected": {
|
"securityConnected": {
|
||||||
@@ -561,7 +638,6 @@
|
|||||||
"totalVideos": "Total Videos",
|
"totalVideos": "Total Videos",
|
||||||
"totalViews": "Total Views",
|
"totalViews": "Total Views",
|
||||||
"storageUsed": "Storage Used",
|
"storageUsed": "Storage Used",
|
||||||
"uploadsThisMonth": "Uploads This Month",
|
|
||||||
"trendVsLastMonth": "vs last month"
|
"trendVsLastMonth": "vs last month"
|
||||||
},
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
@@ -608,7 +684,7 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Storage Usage",
|
"title": "Storage Usage",
|
||||||
"usedOfLimit": "{used} of {limit} used",
|
"usedOfLimit": "{{used}} of {{limit}} used",
|
||||||
"breakdown": {
|
"breakdown": {
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
"thumbnails": "Thumbnails & Assets",
|
"thumbnails": "Thumbnails & Assets",
|
||||||
@@ -628,19 +704,19 @@
|
|||||||
"uploadAction": "Upload Video",
|
"uploadAction": "Upload Video",
|
||||||
"uploadDropTitle": "Drop to upload",
|
"uploadDropTitle": "Drop to upload",
|
||||||
"uploadDropSubtitle": "Files will be added to the upload queue",
|
"uploadDropSubtitle": "Files will be added to the upload queue",
|
||||||
"deleteSelectedConfirm": "Delete {count} videos?",
|
"deleteSelectedConfirm": "Delete {{count}} videos?",
|
||||||
"deleteSingleConfirm": "Are you sure you want to delete this video?",
|
"deleteSingleConfirm": "Are you sure you want to delete this video?",
|
||||||
"retry": "Try Again",
|
"retry": "Try Again",
|
||||||
"emptyTitle": "No videos found",
|
"emptyTitle": "No videos found",
|
||||||
"emptyDescription": "You haven't uploaded any videos yet. Start by uploading your first video!",
|
"emptyDescription": "You haven't uploaded any videos yet. Start by uploading your first video!",
|
||||||
"emptyAction": "Upload Video",
|
"emptyAction": "Upload Video",
|
||||||
"duplicateSummary": "Duplicate files skipped",
|
"duplicateSummary": "Duplicate files skipped",
|
||||||
"duplicateDetailOne": "{count} file is already in the queue.",
|
"duplicateDetailOne": "{{count}} file is already in the queue.",
|
||||||
"duplicateDetailOther": "{count} files are already in the queue."
|
"duplicateDetailOther": "{{count}} files are already in the queue."
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"searchPlaceholder": "Search videos...",
|
"searchPlaceholder": "Search videos...",
|
||||||
"rangeOfTotal": "{first}–{last} of {total}",
|
"rangeOfTotal": "{first}–{last} of {{total}}",
|
||||||
"previousPageAria": "Previous page",
|
"previousPageAria": "Previous page",
|
||||||
"nextPageAria": "Next page",
|
"nextPageAria": "Next page",
|
||||||
"allStatus": "All Status",
|
"allStatus": "All Status",
|
||||||
@@ -660,7 +736,7 @@
|
|||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"selected": "{count} selected",
|
"selected": "{{count}} selected",
|
||||||
"delete": "Delete"
|
"delete": "Delete"
|
||||||
},
|
},
|
||||||
"copyModal": {
|
"copyModal": {
|
||||||
@@ -684,10 +760,13 @@
|
|||||||
"title": "Edit video",
|
"title": "Edit video",
|
||||||
"titleLabel": "Title",
|
"titleLabel": "Title",
|
||||||
"titlePlaceholder": "Enter video title",
|
"titlePlaceholder": "Enter video title",
|
||||||
"descriptionLabel": "Description",
|
"adTemplateLabel": "Ad Template",
|
||||||
"descriptionPlaceholder": "Enter video description",
|
"adTemplateNone": "No ads",
|
||||||
|
"adTemplateDefault": "Default",
|
||||||
|
"adTemplateUpgradeHint": "Upgrade your plan to customize ad templates for this video.",
|
||||||
|
"adTemplateNoAdsHint": "No ad template selected. This video will play without ads.",
|
||||||
"subtitlesTitle": "Subtitles",
|
"subtitlesTitle": "Subtitles",
|
||||||
"subtitleTracks": "{count} tracks",
|
"subtitleTracks": "{{count}} tracks",
|
||||||
"noSubtitles": "No subtitles uploaded yet",
|
"noSubtitles": "No subtitles uploaded yet",
|
||||||
"uploadSubtitle": "Upload Subtitle",
|
"uploadSubtitle": "Upload Subtitle",
|
||||||
"subtitleFile": "Subtitle File (VTT, SRT, ASS, SSA)",
|
"subtitleFile": "Subtitle File (VTT, SRT, ASS, SSA)",
|
||||||
@@ -774,8 +853,8 @@
|
|||||||
"payments": "Payments"
|
"payments": "Payments"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "{count} notifications",
|
"total": "{{count}} notifications",
|
||||||
"unread": "{count} unread"
|
"unread": "{{count}} unread"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"markAllRead": "Mark all read",
|
"markAllRead": "Mark all read",
|
||||||
@@ -796,9 +875,9 @@
|
|||||||
"subtitle": "You're all caught up! Check back later."
|
"subtitle": "You're all caught up! Check back later."
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"minutesAgo": "{count} minutes ago",
|
"minutesAgo": "{{count}} minutes ago",
|
||||||
"hoursAgo": "{count} hours ago",
|
"hoursAgo": "{{count}} hours ago",
|
||||||
"daysAgo": "{count} days ago"
|
"daysAgo": "{{count}} days ago"
|
||||||
},
|
},
|
||||||
"mocks": {
|
"mocks": {
|
||||||
"videoProcessed": {
|
"videoProcessed": {
|
||||||
@@ -830,23 +909,23 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Upload Videos",
|
"title": "Upload Videos",
|
||||||
"subtitle": "Add up to {maxItems} videos per batch",
|
"subtitle": "Add up to {{maxItems}} videos per batch",
|
||||||
"mode": {
|
"mode": {
|
||||||
"local": "Local",
|
"local": "Local",
|
||||||
"remote": "Remote URL"
|
"remote": "Remote URL"
|
||||||
},
|
},
|
||||||
"queueFullTitle": "Queue is full",
|
"queueFullTitle": "Queue is full",
|
||||||
"queueFullDescription": "Maximum {maxItems} videos per batch. Start or clear the current queue first.",
|
"queueFullDescription": "Maximum {{maxItems}} videos per batch. Start or clear the current queue first.",
|
||||||
"slotsRemaining": "{remaining} / {maxItems} slots remaining",
|
"slotsRemaining": "{{remaining}} / {{maxItems}} slots remaining",
|
||||||
"formatsHint": "MP4, MOV, MKV · max 10 GB per file",
|
"formatsHint": "MP4, MOV, MKV · max 10 GB per file",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"startUpload": "Start Upload ({count})",
|
"startUpload": "Start Upload ({{count}})",
|
||||||
"duplicateFilesSummary": "Duplicate files skipped",
|
"duplicateFilesSummary": "Duplicate files skipped",
|
||||||
"duplicateFilesDetailOne": "{count} file is already in the queue.",
|
"duplicateFilesDetailOne": "{{count}} file is already in the queue.",
|
||||||
"duplicateFilesDetailOther": "{count} files are already in the queue.",
|
"duplicateFilesDetailOther": "{{count}} files are already in the queue.",
|
||||||
"duplicateUrlsSummary": "Duplicate URLs skipped",
|
"duplicateUrlsSummary": "Duplicate URLs skipped",
|
||||||
"duplicateUrlsDetailOne": "{count} URL is already in the queue.",
|
"duplicateUrlsDetailOne": "{{count}} URL is already in the queue.",
|
||||||
"duplicateUrlsDetailOther": "{count} URLs are already in the queue."
|
"duplicateUrlsDetailOther": "{{count}} URLs are already in the queue."
|
||||||
},
|
},
|
||||||
"dropzone": {
|
"dropzone": {
|
||||||
"releaseToAdd": "Release to add",
|
"releaseToAdd": "Release to add",
|
||||||
@@ -869,7 +948,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Pending",
|
"pending": "Pending",
|
||||||
"uploading": "Uploading...",
|
"uploading": "Uploading...",
|
||||||
"uploadingThreads": "Uploading · {threads} threads",
|
"uploadingThreads": "Uploading · {{threads}} threads",
|
||||||
"processing": "Processing...",
|
"processing": "Processing...",
|
||||||
"complete": "Done",
|
"complete": "Done",
|
||||||
"error": "Failed",
|
"error": "Failed",
|
||||||
@@ -882,7 +961,7 @@
|
|||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"title": "Quick Settings",
|
"title": "Quick Settings",
|
||||||
"applyToPending": "Apply to {count} pending files",
|
"applyToPending": "Apply to {{count}} pending files",
|
||||||
"selectCategory": "Select category...",
|
"selectCategory": "Select category...",
|
||||||
"category": {
|
"category": {
|
||||||
"learning": "Learning",
|
"learning": "Learning",
|
||||||
@@ -895,15 +974,15 @@
|
|||||||
},
|
},
|
||||||
"indicator": {
|
"indicator": {
|
||||||
"allDone": "All done",
|
"allDone": "All done",
|
||||||
"uploading": "Uploading {count} files...",
|
"uploading": "Uploading {{count}} files...",
|
||||||
"waiting": "{count} files waiting",
|
"waiting": "{{count}} files waiting",
|
||||||
"completeProgress": "{complete} of {total} complete",
|
"completeProgress": "{{complete}} of {{total}} complete",
|
||||||
"start": "Start",
|
"start": "Start",
|
||||||
"viewVideos": "View Videos",
|
"viewVideos": "View Videos",
|
||||||
"addMoreFiles": "Add more files"
|
"addMoreFiles": "Add more files"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"chunkUploadFailed": "Failed to upload chunk {index}",
|
"chunkUploadFailed": "Failed to upload chunk {{index}}",
|
||||||
"mergeFailed": "Merge failed"
|
"mergeFailed": "Merge failed"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"actions": "Hành động",
|
"actions": "Hành động",
|
||||||
"status": "Trạng thái",
|
"status": "Trạng thái",
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
"selected": "{count} mục đã chọn",
|
"selected": "{{count}} mục đã chọn",
|
||||||
"copy": "Sao chép"
|
"copy": "Sao chép"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -211,6 +211,10 @@
|
|||||||
"chromecast": {
|
"chromecast": {
|
||||||
"title": "Chromecast",
|
"title": "Chromecast",
|
||||||
"description": "Cho phép cast tới thiết bị Chromecast"
|
"description": "Cho phép cast tới thiết bị Chromecast"
|
||||||
|
},
|
||||||
|
"encrytion_m3u8": {
|
||||||
|
"title": "Mã hóa HLS (m3u8)",
|
||||||
|
"description": "Bật mã hóa cho luồng HLS (Chống download trái phép, chỉ dành cho VIP)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -240,10 +244,12 @@
|
|||||||
"clearDataReject": "Hủy"
|
"clearDataReject": "Hủy"
|
||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"deleteAccountSummary": "Đã gửi yêu cầu xóa tài khoản",
|
"deleteAccountSummary": "Đã xóa tài khoản",
|
||||||
"deleteAccountDetail": "Yêu cầu xóa tài khoản của bạn đã được gửi.",
|
"deleteAccountDetail": "Tài khoản và dữ liệu liên quan của bạn đã bị xóa vĩnh viễn.",
|
||||||
"clearDataSummary": "Đã xóa dữ liệu",
|
"clearDataSummary": "Đã xóa dữ liệu",
|
||||||
"clearDataDetail": "Toàn bộ dữ liệu của bạn đã bị xóa vĩnh viễn."
|
"clearDataDetail": "Toàn bộ dữ liệu của bạn đã bị xóa vĩnh viễn.",
|
||||||
|
"failedSummary": "Thao tác thất bại",
|
||||||
|
"failedDetail": "Không thể hoàn tất thao tác đã yêu cầu."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domainsDns": {
|
"domainsDns": {
|
||||||
@@ -282,13 +288,18 @@
|
|||||||
"removedSummary": "Đã xóa tên miền",
|
"removedSummary": "Đã xóa tên miền",
|
||||||
"removedDetail": "{domain} đã được xóa khỏi whitelist.",
|
"removedDetail": "{domain} đã được xóa khỏi whitelist.",
|
||||||
"copiedSummary": "Đã sao chép",
|
"copiedSummary": "Đã sao chép",
|
||||||
"copiedDetail": "Đã sao chép mã nhúng vào clipboard."
|
"copiedDetail": "Đã sao chép mã nhúng vào clipboard.",
|
||||||
|
"failedSummary": "Thao tác thất bại",
|
||||||
|
"failedDetail": "Không thể tải hoặc cập nhật danh sách tên miền."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"adsVast": {
|
"adsVast": {
|
||||||
"createTemplate": "Tạo mẫu",
|
"createTemplate": "Tạo mẫu",
|
||||||
"infoBanner": "VAST (Video Ad Serving Template) là schema XML dùng để phân phối ad tags cho trình phát video.",
|
"infoBanner": "VAST (Video Ad Serving Template) là schema XML dùng để phân phối ad tags cho trình phát video.",
|
||||||
"createdOn": "Tạo ngày {date}",
|
"readOnlyTitle": "Cần nâng cấp gói",
|
||||||
|
"readOnlyMessage": "Ads & VAST ở chế độ chỉ đọc với gói free. Hãy nâng cấp gói để tạo, sửa, xóa, bật/tắt hoặc đặt mẫu mặc định.",
|
||||||
|
"defaultBadge": "Mặc định",
|
||||||
|
"createdOn": "Tạo ngày {{date}}",
|
||||||
"emptyTitle": "Chưa có mẫu VAST",
|
"emptyTitle": "Chưa có mẫu VAST",
|
||||||
"emptySubtitle": "Tạo mẫu để bắt đầu kiếm tiền từ video",
|
"emptySubtitle": "Tạo mẫu để bắt đầu kiếm tiền từ video",
|
||||||
"formats": {
|
"formats": {
|
||||||
@@ -300,6 +311,10 @@
|
|||||||
"enabled": "bật",
|
"enabled": "bật",
|
||||||
"disabled": "tắt"
|
"disabled": "tắt"
|
||||||
},
|
},
|
||||||
|
"actions": {
|
||||||
|
"default": "Mặc định",
|
||||||
|
"setDefault": "Đặt mặc định"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"template": "Mẫu",
|
"template": "Mẫu",
|
||||||
"format": "Định dạng",
|
"format": "Định dạng",
|
||||||
@@ -315,6 +330,10 @@
|
|||||||
"adFormat": "Định dạng quảng cáo",
|
"adFormat": "Định dạng quảng cáo",
|
||||||
"adInterval": "Khoảng cách quảng cáo (giây)",
|
"adInterval": "Khoảng cách quảng cáo (giây)",
|
||||||
"adIntervalPlaceholder": "30",
|
"adIntervalPlaceholder": "30",
|
||||||
|
"defaultLabel": "Mẫu mặc định",
|
||||||
|
"defaultCheckbox": "Dùng mẫu này mặc định cho video mới",
|
||||||
|
"defaultHint": "Khi bật, video mới tạo sẽ tự động dùng mẫu đang active này.",
|
||||||
|
"defaultDisabledHint": "Hãy bật mẫu này trước khi đặt làm mặc định.",
|
||||||
"update": "Cập nhật",
|
"update": "Cập nhật",
|
||||||
"create": "Tạo"
|
"create": "Tạo"
|
||||||
},
|
},
|
||||||
@@ -339,11 +358,17 @@
|
|||||||
"createdDetail": "Mẫu VAST đã được tạo.",
|
"createdDetail": "Mẫu VAST đã được tạo.",
|
||||||
"enabledSummary": "Đã bật mẫu",
|
"enabledSummary": "Đã bật mẫu",
|
||||||
"disabledSummary": "Đã tắt mẫu",
|
"disabledSummary": "Đã tắt mẫu",
|
||||||
|
"defaultUpdatedSummary": "Đã cập nhật mặc định",
|
||||||
|
"defaultUpdatedDetail": "{name} hiện là mẫu mặc định cho video mới.",
|
||||||
|
"upgradeRequiredSummary": "Cần nâng cấp gói",
|
||||||
|
"upgradeRequiredDetail": "Hãy nâng cấp gói để quản lý Ads & VAST.",
|
||||||
"toggleDetail": "{name} đã được {state}.",
|
"toggleDetail": "{name} đã được {state}.",
|
||||||
"deletedSummary": "Đã xóa mẫu",
|
"deletedSummary": "Đã xóa mẫu",
|
||||||
"deletedDetail": "Mẫu VAST đã được gỡ bỏ.",
|
"deletedDetail": "Mẫu VAST đã được gỡ bỏ.",
|
||||||
"copiedSummary": "Đã sao chép",
|
"copiedSummary": "Đã sao chép",
|
||||||
"copiedDetail": "Đã sao chép URL vào clipboard."
|
"copiedDetail": "Đã sao chép URL vào clipboard.",
|
||||||
|
"failedSummary": "Thao tác thất bại",
|
||||||
|
"failedDetail": "Không thể tải hoặc cập nhật mẫu VAST."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"profile": {
|
"profile": {
|
||||||
@@ -353,7 +378,7 @@
|
|||||||
"username": "Tên người dùng",
|
"username": "Tên người dùng",
|
||||||
"email": "Địa chỉ email",
|
"email": "Địa chỉ email",
|
||||||
"storageUsage": "Dung lượng sử dụng",
|
"storageUsage": "Dung lượng sử dụng",
|
||||||
"storageUsedOfLimit": "Đã dùng {used} trên {limit}",
|
"storageUsedOfLimit": "Đã dùng {{used}} trên {{limit}}",
|
||||||
"editProfile": "Chỉnh sửa hồ sơ",
|
"editProfile": "Chỉnh sửa hồ sơ",
|
||||||
"changePassword": "Đổi mật khẩu"
|
"changePassword": "Đổi mật khẩu"
|
||||||
},
|
},
|
||||||
@@ -373,26 +398,50 @@
|
|||||||
},
|
},
|
||||||
"billing": {
|
"billing": {
|
||||||
"walletBalance": "Số dư ví",
|
"walletBalance": "Số dư ví",
|
||||||
"currentBalance": "Số dư hiện tại: {balance}",
|
"currentBalance": "Số dư hiện tại: {{balance}}",
|
||||||
"topUp": "Nạp tiền",
|
"topUp": "Nạp tiền",
|
||||||
"availablePlans": "Các gói khả dụng",
|
"availablePlans": "Các gói khả dụng",
|
||||||
"availablePlansHint": "Chọn gói phù hợp nhất với nhu cầu của bạn",
|
"availablePlansHint": "Chọn gói phù hợp nhất với nhu cầu của bạn",
|
||||||
"planStorage": "{storage} dung lượng",
|
"planStorage": "{{storage}} dung lượng",
|
||||||
"planDuration": "{duration} thời lượng tối đa",
|
"planDuration": "{{duration}} thời lượng tối đa",
|
||||||
"planUploads": "{count} lượt tải / ngày",
|
"planUploads": "{{count}} lượt tải / ngày",
|
||||||
"currentPlan": "Gói hiện tại",
|
"currentPlan": "Gói hiện tại",
|
||||||
"processing": "Đang xử lý...",
|
"processing": "Đang xử lý...",
|
||||||
"upgrade": "Nâng cấp",
|
"upgrade": "Nâng cấp",
|
||||||
"storage": "Dung lượng",
|
"storage": "Dung lượng",
|
||||||
"storageUsedOfLimit": "Đã dùng {used} trên {limit}",
|
"storageUsedOfLimit": "Đã dùng {{used}} trên {{limit}}",
|
||||||
"monthlyUploads": "Lượt tải tháng này",
|
"totalVideos": "Tổng video",
|
||||||
"uploadsUsedOfLimit": "{used} trên {limit} lượt tải",
|
"totalVideosUsedOfLimit": "{{used}} trên {{limit}} video",
|
||||||
"paymentHistory": "Lịch sử thanh toán",
|
"paymentHistory": "Lịch sử thanh toán",
|
||||||
"paymentHistorySubtitle": "Các khoản thanh toán và hóa đơn trước đây của bạn",
|
"paymentHistorySubtitle": "Các khoản thanh toán và hóa đơn trước đây của bạn",
|
||||||
"noPaymentHistory": "Không tìm thấy lịch sử thanh toán.",
|
"noPaymentHistory": "Không tìm thấy lịch sử thanh toán.",
|
||||||
"download": "Tải xuống",
|
"download": "Tải xuống",
|
||||||
"durationMinutes": "{minutes} phút",
|
"durationMinutes": "{{minutes}} phút",
|
||||||
"unknownPlan": "Không xác định",
|
"unknownPlan": "Không xác định",
|
||||||
|
"walletTopup": "Nạp tiền ví",
|
||||||
|
"termOption": "{{months}} tháng",
|
||||||
|
"paymentMethod": {
|
||||||
|
"wallet": "Dùng số dư ví",
|
||||||
|
"topup": "Nạp thêm và thanh toán"
|
||||||
|
},
|
||||||
|
"subscription": {
|
||||||
|
"activeTitle": "Gói đang hoạt động",
|
||||||
|
"activeDescription": " {{plan}} có hiệu lực đến {{date}}",
|
||||||
|
"expiringTitle": "Sắp hết hạn",
|
||||||
|
"expiringDescription": " {{plan}} sẽ hết hạn vào {{date}}",
|
||||||
|
"expiredTitle": "Gói đã hết hạn",
|
||||||
|
"expiredDescription": "Gói gần nhất của bạn đã kết thúc vào {{date}}",
|
||||||
|
"freeTitle": "Gói miễn phí",
|
||||||
|
"freeDescription": "Bạn hiện đang sử dụng gói miễn phí."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"validUntil": "Hiệu lực đến {{date}}"
|
||||||
|
},
|
||||||
|
"cycle": {
|
||||||
|
"MONTHLY": "Tháng",
|
||||||
|
"QUARTERLY": "Quý",
|
||||||
|
"YEARLY": "Năm"
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"date": "Ngày",
|
"date": "Ngày",
|
||||||
"amount": "Số tiền",
|
"amount": "Số tiền",
|
||||||
@@ -405,6 +454,31 @@
|
|||||||
"failed": "Thất bại",
|
"failed": "Thất bại",
|
||||||
"pending": "Đang chờ"
|
"pending": "Đang chờ"
|
||||||
},
|
},
|
||||||
|
"upgradeDialog": {
|
||||||
|
"title": "Nâng cấp hoặc gia hạn gói",
|
||||||
|
"selectedPlan": "Gói đã chọn",
|
||||||
|
"basePrice": "Giá cơ bản mỗi tháng",
|
||||||
|
"perMonthBase": "Được dùng làm giá cơ sở cho kỳ hạn bạn chọn",
|
||||||
|
"termTitle": "Kỳ hạn thanh toán",
|
||||||
|
"termHint": "Chọn thời gian bạn muốn kích hoạt hoặc gia hạn gói này.",
|
||||||
|
"totalLabel": "Tổng tiền",
|
||||||
|
"walletBalanceLabel": "Số dư ví",
|
||||||
|
"shortfallLabel": "Còn thiếu",
|
||||||
|
"paymentMethodTitle": "Bạn muốn thanh toán theo cách nào?",
|
||||||
|
"paymentMethodHint": "Nếu ví chưa đủ, bạn có thể nạp phần thiếu và hoàn tất mua gói trong cùng một flow.",
|
||||||
|
"walletOptionDescription": "Thử dùng số dư ví hiện tại trước.",
|
||||||
|
"topupOptionDescription": "Nạp ít nhất {{shortfall}} để hoàn tất giao dịch.",
|
||||||
|
"walletCoveredHint": "Số dư ví hiện tại đã đủ để thanh toán gói này.",
|
||||||
|
"walletInsufficientHint": "Ví của bạn còn thiếu {{shortfall}}. Hãy chuyển sang phương án nạp thêm để hoàn tất giao dịch.",
|
||||||
|
"topupAmountLabel": "Số tiền nạp thêm",
|
||||||
|
"topupAmountPlaceholder": "Nhập số tiền muốn nạp",
|
||||||
|
"topupAmountHint": "Phần tiền nạp vượt quá {{shortfall}} sẽ được giữ lại trong ví sau khi nâng cấp.",
|
||||||
|
"payWithWallet": "Thanh toán bằng ví",
|
||||||
|
"topupAndUpgrade": "Nạp thêm và nâng cấp",
|
||||||
|
"choosePlan": "Chọn gói này",
|
||||||
|
"selecting": "Đang mở...",
|
||||||
|
"footerHint": "Nếu gói hiện tại vẫn còn hạn, kỳ hạn mới sẽ được cộng tiếp từ ngày hết hạn hiện tại."
|
||||||
|
},
|
||||||
"topupDialog": {
|
"topupDialog": {
|
||||||
"title": "Nạp tiền vào ví",
|
"title": "Nạp tiền vào ví",
|
||||||
"subtitle": "Chọn số tiền hoặc nhập số tiền tùy chỉnh để nạp vào ví.",
|
"subtitle": "Chọn số tiền hoặc nhập số tiền tùy chỉnh để nạp vào ví.",
|
||||||
@@ -415,17 +489,19 @@
|
|||||||
},
|
},
|
||||||
"toast": {
|
"toast": {
|
||||||
"subscriptionSuccessSummary": "Đăng ký thành công",
|
"subscriptionSuccessSummary": "Đăng ký thành công",
|
||||||
"subscriptionSuccessDetail": "Đăng ký gói {plan} thành công",
|
"subscriptionSuccessDetail": "Đã kích hoạt {{plan}} trong {{term}}",
|
||||||
"subscriptionFailedSummary": "Đăng ký thất bại",
|
"subscriptionFailedSummary": "Đăng ký thất bại",
|
||||||
"subscriptionFailedDetail": "Không thể đăng ký gói",
|
"subscriptionFailedDetail": "Không thể đăng ký gói",
|
||||||
"topupSuccessSummary": "Nạp tiền thành công",
|
"topupSuccessSummary": "Nạp tiền thành công",
|
||||||
"topupSuccessDetail": "{amount} đã được cộng vào ví của bạn.",
|
"topupSuccessDetail": "{{amount}} đã được cộng vào ví của bạn.",
|
||||||
"topupFailedSummary": "Nạp tiền thất bại",
|
"topupFailedSummary": "Nạp tiền thất bại",
|
||||||
"topupFailedDetail": "Không thể xử lý nạp tiền.",
|
"topupFailedDetail": "Không thể xử lý nạp tiền.",
|
||||||
"downloadingSummary": "Đang tải",
|
"downloadingSummary": "Đang tải",
|
||||||
"downloadingDetail": "Đang tải hóa đơn #{invoiceId}...",
|
"downloadingDetail": "Đang tải hóa đơn #{invoiceId}...",
|
||||||
"downloadedSummary": "Đã tải xong",
|
"downloadedSummary": "Đã tải xong",
|
||||||
"downloadedDetail": "Hóa đơn #{invoiceId} đã được tải thành công"
|
"downloadedDetail": "Hóa đơn #{invoiceId} đã được tải thành công",
|
||||||
|
"downloadFailedSummary": "Tải xuống thất bại",
|
||||||
|
"downloadFailedDetail": "Không thể tải hóa đơn."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"securityConnected": {
|
"securityConnected": {
|
||||||
@@ -561,7 +637,6 @@
|
|||||||
"totalVideos": "Tổng số video",
|
"totalVideos": "Tổng số video",
|
||||||
"totalViews": "Tổng lượt xem",
|
"totalViews": "Tổng lượt xem",
|
||||||
"storageUsed": "Dung lượng đã dùng",
|
"storageUsed": "Dung lượng đã dùng",
|
||||||
"uploadsThisMonth": "Lượt tải lên tháng này",
|
|
||||||
"trendVsLastMonth": "so với tháng trước"
|
"trendVsLastMonth": "so với tháng trước"
|
||||||
},
|
},
|
||||||
"quickActions": {
|
"quickActions": {
|
||||||
@@ -608,7 +683,7 @@
|
|||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"title": "Sử dụng dung lượng",
|
"title": "Sử dụng dung lượng",
|
||||||
"usedOfLimit": "Đã dùng {used} trên {limit}",
|
"usedOfLimit": "Đã dùng {{used}} trên {{limit}}",
|
||||||
"breakdown": {
|
"breakdown": {
|
||||||
"videos": "Video",
|
"videos": "Video",
|
||||||
"thumbnails": "Thumbnail & tài nguyên",
|
"thumbnails": "Thumbnail & tài nguyên",
|
||||||
@@ -628,15 +703,15 @@
|
|||||||
"uploadAction": "Tải video lên",
|
"uploadAction": "Tải video lên",
|
||||||
"uploadDropTitle": "Thả để tải lên",
|
"uploadDropTitle": "Thả để tải lên",
|
||||||
"uploadDropSubtitle": "Tệp sẽ được thêm vào hàng đợi tải lên",
|
"uploadDropSubtitle": "Tệp sẽ được thêm vào hàng đợi tải lên",
|
||||||
"deleteSelectedConfirm": "Xóa {count} video?",
|
"deleteSelectedConfirm": "Xóa {{count}} video?",
|
||||||
"deleteSingleConfirm": "Bạn có chắc muốn xóa video này?",
|
"deleteSingleConfirm": "Bạn có chắc muốn xóa video này?",
|
||||||
"retry": "Thử lại",
|
"retry": "Thử lại",
|
||||||
"emptyTitle": "Không có video",
|
"emptyTitle": "Không có video",
|
||||||
"emptyDescription": "Bạn chưa tải video nào. Hãy bắt đầu với video đầu tiên!",
|
"emptyDescription": "Bạn chưa tải video nào. Hãy bắt đầu với video đầu tiên!",
|
||||||
"emptyAction": "Tải video lên",
|
"emptyAction": "Tải video lên",
|
||||||
"duplicateSummary": "Đã bỏ qua tệp trùng lặp",
|
"duplicateSummary": "Đã bỏ qua tệp trùng lặp",
|
||||||
"duplicateDetailOne": "{count} tệp đã có trong hàng đợi.",
|
"duplicateDetailOne": "{{count}} tệp đã có trong hàng đợi.",
|
||||||
"duplicateDetailOther": "{count} tệp đã có trong hàng đợi."
|
"duplicateDetailOther": "{{count}} tệp đã có trong hàng đợi."
|
||||||
},
|
},
|
||||||
"filters": {
|
"filters": {
|
||||||
"searchPlaceholder": "Tìm kiếm video...",
|
"searchPlaceholder": "Tìm kiếm video...",
|
||||||
@@ -660,7 +735,7 @@
|
|||||||
"delete": "Xóa"
|
"delete": "Xóa"
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
"selected": "{count} mục đã chọn",
|
"selected": "{{count}} mục đã chọn",
|
||||||
"delete": "Xóa"
|
"delete": "Xóa"
|
||||||
},
|
},
|
||||||
"copyModal": {
|
"copyModal": {
|
||||||
@@ -684,10 +759,13 @@
|
|||||||
"title": "Chỉnh sửa video",
|
"title": "Chỉnh sửa video",
|
||||||
"titleLabel": "Tiêu đề",
|
"titleLabel": "Tiêu đề",
|
||||||
"titlePlaceholder": "Nhập tiêu đề video",
|
"titlePlaceholder": "Nhập tiêu đề video",
|
||||||
"descriptionLabel": "Mô tả",
|
"adTemplateLabel": "Mẫu quảng cáo",
|
||||||
"descriptionPlaceholder": "Nhập mô tả video",
|
"adTemplateNone": "Không có quảng cáo",
|
||||||
|
"adTemplateDefault": "Mặc định",
|
||||||
|
"adTemplateUpgradeHint": "Nâng cấp gói để tùy chỉnh mẫu quảng cáo cho video này.",
|
||||||
|
"adTemplateNoAdsHint": "Chưa chọn mẫu quảng cáo. Video này sẽ phát không có quảng cáo.",
|
||||||
"subtitlesTitle": "Phụ đề",
|
"subtitlesTitle": "Phụ đề",
|
||||||
"subtitleTracks": "{count} track",
|
"subtitleTracks": "{{count}} track",
|
||||||
"noSubtitles": "Chưa có phụ đề",
|
"noSubtitles": "Chưa có phụ đề",
|
||||||
"uploadSubtitle": "Tải phụ đề",
|
"uploadSubtitle": "Tải phụ đề",
|
||||||
"subtitleFile": "Tệp phụ đề (VTT, SRT, ASS, SSA)",
|
"subtitleFile": "Tệp phụ đề (VTT, SRT, ASS, SSA)",
|
||||||
@@ -774,8 +852,8 @@
|
|||||||
"payments": "Thanh toán"
|
"payments": "Thanh toán"
|
||||||
},
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"total": "{count} thông báo",
|
"total": "{{count}} thông báo",
|
||||||
"unread": "{count} chưa đọc"
|
"unread": "{{count}} chưa đọc"
|
||||||
},
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"markAllRead": "Đánh dấu đã đọc tất cả",
|
"markAllRead": "Đánh dấu đã đọc tất cả",
|
||||||
@@ -796,9 +874,9 @@
|
|||||||
"subtitle": "Bạn đã xem hết! Hãy quay lại sau."
|
"subtitle": "Bạn đã xem hết! Hãy quay lại sau."
|
||||||
},
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"minutesAgo": "{count} phút trước",
|
"minutesAgo": "{{count}} phút trước",
|
||||||
"hoursAgo": "{count} giờ trước",
|
"hoursAgo": "{{count}} giờ trước",
|
||||||
"daysAgo": "{count} ngày trước"
|
"daysAgo": "{{count}} ngày trước"
|
||||||
},
|
},
|
||||||
"mocks": {
|
"mocks": {
|
||||||
"videoProcessed": {
|
"videoProcessed": {
|
||||||
@@ -830,23 +908,23 @@
|
|||||||
"upload": {
|
"upload": {
|
||||||
"dialog": {
|
"dialog": {
|
||||||
"title": "Tải video lên",
|
"title": "Tải video lên",
|
||||||
"subtitle": "Thêm tối đa {maxItems} video mỗi đợt",
|
"subtitle": "Thêm tối đa {{maxItems}} video mỗi đợt",
|
||||||
"mode": {
|
"mode": {
|
||||||
"local": "Tệp cục bộ",
|
"local": "Tệp cục bộ",
|
||||||
"remote": "URL từ xa"
|
"remote": "URL từ xa"
|
||||||
},
|
},
|
||||||
"queueFullTitle": "Hàng đợi đã đầy",
|
"queueFullTitle": "Hàng đợi đã đầy",
|
||||||
"queueFullDescription": "Tối đa {maxItems} video mỗi đợt. Hãy bắt đầu hoặc xóa hàng đợi hiện tại trước.",
|
"queueFullDescription": "Tối đa {{maxItems}} video mỗi đợt. Hãy bắt đầu hoặc xóa hàng đợi hiện tại trước.",
|
||||||
"slotsRemaining": "Còn {remaining} / {maxItems} vị trí",
|
"slotsRemaining": "Còn {{remaining}} / {{maxItems}} vị trí",
|
||||||
"formatsHint": "MP4, MOV, MKV · tối đa 10 GB mỗi tệp",
|
"formatsHint": "MP4, MOV, MKV · tối đa 10 GB mỗi tệp",
|
||||||
"close": "Đóng",
|
"close": "Đóng",
|
||||||
"startUpload": "Bắt đầu tải ({count})",
|
"startUpload": "Bắt đầu tải ({{count}})",
|
||||||
"duplicateFilesSummary": "Đã bỏ qua tệp trùng lặp",
|
"duplicateFilesSummary": "Đã bỏ qua tệp trùng lặp",
|
||||||
"duplicateFilesDetailOne": "{count} tệp đã có trong hàng đợi.",
|
"duplicateFilesDetailOne": "{{count}} tệp đã có trong hàng đợi.",
|
||||||
"duplicateFilesDetailOther": "{count} tệp đã có trong hàng đợi.",
|
"duplicateFilesDetailOther": "{{count}} tệp đã có trong hàng đợi.",
|
||||||
"duplicateUrlsSummary": "Đã bỏ qua URL trùng lặp",
|
"duplicateUrlsSummary": "Đã bỏ qua URL trùng lặp",
|
||||||
"duplicateUrlsDetailOne": "{count} URL đã có trong hàng đợi.",
|
"duplicateUrlsDetailOne": "{{count}} URL đã có trong hàng đợi.",
|
||||||
"duplicateUrlsDetailOther": "{count} URL đã có trong hàng đợi."
|
"duplicateUrlsDetailOther": "{{count}} URL đã có trong hàng đợi."
|
||||||
},
|
},
|
||||||
"dropzone": {
|
"dropzone": {
|
||||||
"releaseToAdd": "Thả để thêm",
|
"releaseToAdd": "Thả để thêm",
|
||||||
@@ -869,7 +947,7 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"pending": "Chờ tải",
|
"pending": "Chờ tải",
|
||||||
"uploading": "Đang tải lên...",
|
"uploading": "Đang tải lên...",
|
||||||
"uploadingThreads": "Đang tải · {threads} luồng",
|
"uploadingThreads": "Đang tải · {{threads}} luồng",
|
||||||
"processing": "Đang xử lý...",
|
"processing": "Đang xử lý...",
|
||||||
"complete": "Hoàn tất",
|
"complete": "Hoàn tất",
|
||||||
"error": "Thất bại",
|
"error": "Thất bại",
|
||||||
@@ -882,7 +960,7 @@
|
|||||||
},
|
},
|
||||||
"bulkActions": {
|
"bulkActions": {
|
||||||
"title": "Thiết lập nhanh",
|
"title": "Thiết lập nhanh",
|
||||||
"applyToPending": "Áp dụng cho {count} tệp đang chờ",
|
"applyToPending": "Áp dụng cho {{count}} tệp đang chờ",
|
||||||
"selectCategory": "Chọn danh mục...",
|
"selectCategory": "Chọn danh mục...",
|
||||||
"category": {
|
"category": {
|
||||||
"learning": "Học tập",
|
"learning": "Học tập",
|
||||||
@@ -895,15 +973,15 @@
|
|||||||
},
|
},
|
||||||
"indicator": {
|
"indicator": {
|
||||||
"allDone": "Hoàn tất",
|
"allDone": "Hoàn tất",
|
||||||
"uploading": "Đang tải lên {count} tệp...",
|
"uploading": "Đang tải lên {{count}} tệp...",
|
||||||
"waiting": "{count} tệp đang chờ",
|
"waiting": "{{count}} tệp đang chờ",
|
||||||
"completeProgress": "Hoàn tất {complete} / {total}",
|
"completeProgress": "Hoàn tất {{complete}} / {{total}}",
|
||||||
"start": "Bắt đầu",
|
"start": "Bắt đầu",
|
||||||
"viewVideos": "Xem video",
|
"viewVideos": "Xem video",
|
||||||
"addMoreFiles": "Thêm tệp"
|
"addMoreFiles": "Thêm tệp"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"chunkUploadFailed": "Không thể tải phần {index}",
|
"chunkUploadFailed": "Không thể tải phần {{index}}",
|
||||||
"mergeFailed": "Gộp tệp thất bại"
|
"mergeFailed": "Gộp tệp thất bại"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
1378
src/api/client.ts
1378
src/api/client.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
export const customFetch = (url: string, options: RequestInit) => {
|
export const customFetch: typeof fetch = (input, init) => {
|
||||||
return fetch(url, {
|
return fetch(input, {
|
||||||
...options,
|
...init,
|
||||||
credentials: "include",
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
23
src/components/AppTopLoadingBar.vue
Normal file
23
src/components/AppTopLoadingBar.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouteLoading } from '@/composables/useRouteLoading'
|
||||||
|
|
||||||
|
const { visible, progress } = useRouteLoading()
|
||||||
|
|
||||||
|
const barStyle = computed(() => ({
|
||||||
|
transform: `scaleX(${progress.value / 100})`,
|
||||||
|
opacity: visible.value ? '1' : '0',
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-0.75"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="h-full origin-left rounded-r-full bg-primary/50 shadow-[0_0_12px_var(--colors-primary-DEFAULT)] transition-[transform,opacity] duration-200 ease-out"
|
||||||
|
:style="barStyle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,130 +1,51 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||||
|
import { useNotifications } from '@/composables/useNotifications';
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, onMounted, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
// Ensure client-side only rendering to avoid hydration mismatch
|
|
||||||
const isMounted = ref(false);
|
const isMounted = ref(false);
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
isMounted.value = true;
|
isMounted.value = true;
|
||||||
|
void notificationStore.fetchNotifications();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Emit event when visibility changes
|
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: NotificationType;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
time: string;
|
|
||||||
read: boolean;
|
|
||||||
actionUrl?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const drawerRef = ref(null);
|
const drawerRef = ref(null);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const notificationStore = useNotifications();
|
||||||
|
|
||||||
// Mock notifications data
|
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
||||||
const notifications = computed<Notification[]>(() => [
|
const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
|
||||||
{
|
|
||||||
id: '1',
|
|
||||||
type: 'video',
|
|
||||||
title: t('notification.mocks.videoProcessed.title'),
|
|
||||||
message: t('notification.mocks.videoProcessed.message'),
|
|
||||||
time: t('notification.time.minutesAgo', { count: 2 }),
|
|
||||||
read: false,
|
|
||||||
actionUrl: '/video',
|
|
||||||
actionLabel: t('notification.actions.viewVideo')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'payment',
|
|
||||||
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: t('notification.actions.viewReceipt')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'warning',
|
|
||||||
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: t('notification.actions.upgradePlan')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
type: 'success',
|
|
||||||
title: t('notification.mocks.uploadSuccess.title'),
|
|
||||||
message: t('notification.mocks.uploadSuccess.message'),
|
|
||||||
time: t('notification.time.daysAgo', { count: 1 }),
|
|
||||||
read: true
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
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) => {
|
const toggle = (event?: Event) => {
|
||||||
console.log(event);
|
console.log(event);
|
||||||
// Prevent event propagation to avoid immediate closure by onClickOutside
|
|
||||||
if (event) {
|
|
||||||
// We don't stop propagation here to let other listeners work,
|
|
||||||
// but we might need to ignore the trigger element in onClickOutside
|
|
||||||
// However, since the trigger is outside this component, simple toggle logic works
|
|
||||||
// if we use a small delay or ignore ref.
|
|
||||||
// Best approach: "toggle" usually comes from a button click.
|
|
||||||
}
|
|
||||||
visible.value = !visible.value;
|
visible.value = !visible.value;
|
||||||
console.log(visible.value);
|
if (visible.value && !notificationStore.loaded.value) {
|
||||||
|
void notificationStore.fetchNotifications();
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle click outside
|
onClickOutside(drawerRef, () => {
|
||||||
onClickOutside(drawerRef, (event) => {
|
|
||||||
// We can just set visible to false.
|
|
||||||
// Note: If the toggle button is clicked, it might toggle it back on immediately
|
|
||||||
// if the click event propagates.
|
|
||||||
// The user calls `toggle` from the parent's button click handler.
|
|
||||||
// If that button is outside `drawerRef` (which it is), this will fire.
|
|
||||||
// To avoid conflict, we usually check if the target is the trigger.
|
|
||||||
// But we don't have access to the trigger ref here.
|
|
||||||
// A common workaround is to use `ignore` option if we had the ref,
|
|
||||||
// or relying on the fact that if this fires, it sets specific state to false.
|
|
||||||
// If the button click then fires `toggle`, it might set it true again.
|
|
||||||
// Optimization: check if visible is true before closing.
|
|
||||||
if (visible.value) {
|
if (visible.value) {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
|
ignore: ['[name="Notification"]']
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMarkRead = (id: string) => {
|
const handleMarkRead = async (id: string) => {
|
||||||
const notification = mutableNotifications.value.find(n => n.id === id);
|
await notificationStore.markRead(id);
|
||||||
if (notification) notification.read = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
mutableNotifications.value = mutableNotifications.value.filter(n => n.id !== id);
|
await notificationStore.deleteNotification(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
const handleMarkAllRead = async () => {
|
||||||
mutableNotifications.value.forEach(n => n.read = true);
|
await notificationStore.markAllRead();
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(visible, (val) => {
|
watch(visible, (val) => {
|
||||||
@@ -142,7 +63,6 @@ defineExpose({ toggle });
|
|||||||
leave-to-class="opacity-0 -translate-x-4">
|
leave-to-class="opacity-0 -translate-x-4">
|
||||||
<div v-if="visible" ref="drawerRef"
|
<div v-if="visible" ref="drawerRef"
|
||||||
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between p-4">
|
<div class="flex items-center justify-between p-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
|
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
|
||||||
@@ -157,9 +77,19 @@ defineExpose({ toggle });
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Notification List -->
|
|
||||||
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
||||||
<template v-if="mutableNotifications.length > 0">
|
<template v-if="notificationStore.loading.value">
|
||||||
|
<div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
|
||||||
|
<div class="flex-1 space-y-2">
|
||||||
|
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||||
|
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="mutableNotifications.length > 0">
|
||||||
<div v-for="notification in mutableNotifications" :key="notification.id"
|
<div v-for="notification in mutableNotifications" :key="notification.id"
|
||||||
class="border-b border-gray-50 last:border-0">
|
class="border-b border-gray-50 last:border-0">
|
||||||
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
||||||
@@ -167,14 +97,12 @@ defineExpose({ toggle });
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Empty state -->
|
|
||||||
<div v-else class="py-12 text-center">
|
<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>
|
<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">{{ t('notification.empty.title') }}</p>
|
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div v-if="mutableNotifications.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"
|
<router-link to="/notification"
|
||||||
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
||||||
@@ -186,16 +114,3 @@ defineExpose({ toggle });
|
|||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- <style>
|
|
||||||
.notification-popover {
|
|
||||||
border-radius: 16px !important;
|
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.notification-popover .p-popover-content {
|
|
||||||
padding: 0 !important;
|
|
||||||
}
|
|
||||||
</style> -->
|
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<AppTopLoadingBar />
|
||||||
|
</ClientOnly>
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</template>
|
</template>
|
||||||
|
<script setup lang="ts">
|
||||||
|
import ClientOnly from '@/components/ClientOnly';
|
||||||
|
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg>
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||||
<path
|
<path
|
||||||
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
||||||
@@ -8,4 +8,4 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ filled?: boolean }>();
|
defineProps<{ filled?: boolean }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ defineProps<{
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<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">
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 532"><path d="M26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zM138 42c0-9 7-16 16-16h192c9 0 16 7 16 16 0 3 0 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8zm56 267c0 21 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8s-8 4-8 8v16h-5c-24 0-43 19-43 43z" fill="#a6acb9"/><path d="M346 26c9 0 16 7 16 16 0 3-1 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8 0-9 7-16 16-16h192zM126 57l45 86C85 222 10 299 10 427c0 52 43 95 95 95h290c52 0 95-43 95-95 0-128-75-205-161-284l45-86c3-5 4-10 4-15 0-18-14-32-32-32H154c-18 0-32 14-32 32 0 5 1 10 4 15zM26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zm224-185c-4 0-8 4-8 8v16h-5c-24 0-43 19-43 43 0 20 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8z" fill="currentColor"/></svg>
|
||||||
<circle cx="12" cy="12" r="9"/>
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="532" viewBox="6 -258 500 532"><path d="m379-191-46 81c84 77 163 154 163 279 0 52-43 95-95 95H111c-52 0-95-43-95-95C16 44 96-33 179-110l-46-81c-3-6-5-12-5-19 0-21 17-38 38-38h180c21 0 38 17 38 38 0 7-2 13-5 19zM227-88l-1 1C134-4 64 61 64 169c0 26 21 47 47 47h290c26 0 47-21 47-47C448 61 378-4 286-87l-1-1h-58zm-7-48h72l37-64H183l37 64zm40 96c11 0 20 9 20 20v4h8c11 0 20 9 20 20s-9 20-20 20h-47c-7 0-13 6-13 13 0 6 4 11 10 12l42 7c25 4 44 26 44 52s-19 47-44 51v5c0 11-9 20-20 20s-20-9-20-20v-4h-24c-11 0-20-9-20-20s9-20 20-20h56c6 0 12-5 12-12 0-6-4-12-10-13l-42-7c-25-4-44-26-44-51 0-29 23-53 52-53v-4c0-11 9-20 20-20z" fill="currentColor"/></svg>
|
||||||
<path d="M12 16V8"/>
|
|
||||||
<path d="M9.5 10a2.5 2.5 0 0 1 5 0v4a2.5 2.5 0 0 1-5 0"/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
|
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
|
||||||
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
|
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
|
||||||
fill="#a6acb9" />
|
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
||||||
<path
|
<path
|
||||||
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
||||||
fill="#1e3050" />
|
fill="var(--colors-primary-DEFAULT)" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
||||||
<path
|
<path
|
||||||
@@ -14,4 +14,4 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ filled?: boolean }>();
|
defineProps<{ filled?: boolean }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="#a6acb9"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="#1e3050"/></svg>
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
|
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
|
||||||
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
|
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
|
||||||
fill="#a6acb9" />
|
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
||||||
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
|
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
|
||||||
fill="#1e3050" />
|
fill="var(--colors-primary-DEFAULT)" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
||||||
<path
|
<path
|
||||||
@@ -13,4 +13,4 @@
|
|||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ filled?: boolean }>();
|
defineProps<{ filled?: boolean }>();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
128
src/composables/useNotifications.ts
Normal file
128
src/composables/useNotifications.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { client } from '@/api/client';
|
||||||
|
import { computed, ref } from 'vue';
|
||||||
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
|
export type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||||
|
|
||||||
|
export type AppNotification = {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
actionUrl?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
createdAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationApiItem = {
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
read?: boolean;
|
||||||
|
actionUrl?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
action_url?: string;
|
||||||
|
action_label?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const notifications = ref<AppNotification[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const loaded = ref(false);
|
||||||
|
|
||||||
|
const normalizeType = (value?: string): NotificationType => {
|
||||||
|
switch ((value || '').toLowerCase()) {
|
||||||
|
case 'video':
|
||||||
|
case 'payment':
|
||||||
|
case 'warning':
|
||||||
|
case 'error':
|
||||||
|
case 'success':
|
||||||
|
case 'system':
|
||||||
|
return value as NotificationType;
|
||||||
|
default:
|
||||||
|
return 'info';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useNotifications() {
|
||||||
|
const { t, i18next } = useTranslation();
|
||||||
|
|
||||||
|
const formatRelativeTime = (value?: string) => {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
||||||
|
if (minutes < 60) return t('notification.time.minutesAgo', { count: minutes });
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return t('notification.time.hoursAgo', { count: hours });
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return t('notification.time.daysAgo', { count: Math.max(1, days) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapNotification = (item: NotificationApiItem): AppNotification => ({
|
||||||
|
id: item.id || '',
|
||||||
|
type: normalizeType(item.type),
|
||||||
|
title: item.title || '',
|
||||||
|
message: item.message || '',
|
||||||
|
time: formatRelativeTime(item.created_at),
|
||||||
|
read: Boolean(item.read),
|
||||||
|
actionUrl: item.actionUrl || item.action_url || undefined,
|
||||||
|
actionLabel: item.actionLabel || item.action_label || undefined,
|
||||||
|
createdAt: item.created_at,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
|
||||||
|
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
|
||||||
|
loaded.value = true;
|
||||||
|
return notifications.value;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const markRead = async (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
await client.notifications.readCreate(id, { baseUrl: '/r' });
|
||||||
|
const item = notifications.value.find(notification => notification.id === id);
|
||||||
|
if (item) item.read = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteNotification = async (id: string) => {
|
||||||
|
if (!id) return;
|
||||||
|
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
|
||||||
|
notifications.value = notifications.value.filter(notification => notification.id !== id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const markAllRead = async () => {
|
||||||
|
await client.notifications.readAllCreate({ baseUrl: '/r' });
|
||||||
|
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearAll = async () => {
|
||||||
|
await client.notifications.notificationsDelete({ baseUrl: '/r' });
|
||||||
|
notifications.value = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const unreadCount = computed(() => notifications.value.filter(item => !item.read).length);
|
||||||
|
|
||||||
|
return {
|
||||||
|
notifications,
|
||||||
|
loading,
|
||||||
|
loaded,
|
||||||
|
unreadCount,
|
||||||
|
locale: computed(() => i18next.resolvedLanguage),
|
||||||
|
fetchNotifications,
|
||||||
|
markRead,
|
||||||
|
deleteNotification,
|
||||||
|
markAllRead,
|
||||||
|
clearAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
59
src/composables/useRouteLoading.ts
Normal file
59
src/composables/useRouteLoading.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const visible = ref(false)
|
||||||
|
const progress = ref(0)
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
|
||||||
|
visible.value = true
|
||||||
|
progress.value = 8
|
||||||
|
|
||||||
|
timer = setInterval(() => {
|
||||||
|
if (progress.value < 80) {
|
||||||
|
progress.value += Math.random() * 12
|
||||||
|
} else if (progress.value < 95) {
|
||||||
|
progress.value += Math.random() * 3
|
||||||
|
}
|
||||||
|
}, 200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.value = 100
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
progress.value = 0
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer)
|
||||||
|
timer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.value = 100
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
visible.value = false
|
||||||
|
progress.value = 0
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouteLoading() {
|
||||||
|
return {
|
||||||
|
visible,
|
||||||
|
progress,
|
||||||
|
start,
|
||||||
|
finish,
|
||||||
|
fail,
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/composables/useSettingsPreferencesQuery.ts
Normal file
127
src/composables/useSettingsPreferencesQuery.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
|
||||||
|
import { useQuery } from '@pinia/colada';
|
||||||
|
|
||||||
|
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
|
||||||
|
|
||||||
|
export type SettingsPreferencesSnapshot = {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
pushNotifications: boolean;
|
||||||
|
marketingNotifications: boolean;
|
||||||
|
telegramNotifications: boolean;
|
||||||
|
autoplay: boolean;
|
||||||
|
loop: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
showControls: boolean;
|
||||||
|
pip: boolean;
|
||||||
|
airplay: boolean;
|
||||||
|
chromecast: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NotificationSettingsDraft = {
|
||||||
|
email: boolean;
|
||||||
|
push: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
telegram: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PlayerSettingsDraft = {
|
||||||
|
autoplay: boolean;
|
||||||
|
loop: boolean;
|
||||||
|
muted: boolean;
|
||||||
|
showControls: boolean;
|
||||||
|
pip: boolean;
|
||||||
|
airplay: boolean;
|
||||||
|
chromecast: boolean;
|
||||||
|
encrytion_m3u8: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreferencesResponse = {
|
||||||
|
data?: {
|
||||||
|
preferences?: PreferencesSettingsPreferencesRequest;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||||
|
emailNotifications: true,
|
||||||
|
pushNotifications: true,
|
||||||
|
marketingNotifications: false,
|
||||||
|
telegramNotifications: false,
|
||||||
|
autoplay: false,
|
||||||
|
loop: false,
|
||||||
|
muted: false,
|
||||||
|
showControls: true,
|
||||||
|
pip: true,
|
||||||
|
airplay: true,
|
||||||
|
chromecast: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
|
||||||
|
const preferences = (responseData as PreferencesResponse | undefined)?.data?.preferences;
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailNotifications: preferences?.email_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
||||||
|
pushNotifications: preferences?.push_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||||
|
marketingNotifications: preferences?.marketing_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||||
|
telegramNotifications: preferences?.telegram_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||||
|
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
|
||||||
|
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
|
||||||
|
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
|
||||||
|
showControls: preferences?.show_controls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
|
||||||
|
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
|
||||||
|
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
|
||||||
|
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createNotificationSettingsDraft = (
|
||||||
|
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
|
||||||
|
): NotificationSettingsDraft => ({
|
||||||
|
email: snapshot.emailNotifications,
|
||||||
|
push: snapshot.pushNotifications,
|
||||||
|
marketing: snapshot.marketingNotifications,
|
||||||
|
telegram: snapshot.telegramNotifications,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const createPlayerSettingsDraft = (
|
||||||
|
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
|
||||||
|
): PlayerSettingsDraft => ({
|
||||||
|
autoplay: snapshot.autoplay,
|
||||||
|
loop: snapshot.loop,
|
||||||
|
muted: snapshot.muted,
|
||||||
|
showControls: snapshot.showControls,
|
||||||
|
pip: snapshot.pip,
|
||||||
|
airplay: snapshot.airplay,
|
||||||
|
chromecast: snapshot.chromecast,
|
||||||
|
encrytion_m3u8: snapshot.chromecast
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toNotificationPreferencesPayload = (
|
||||||
|
draft: NotificationSettingsDraft,
|
||||||
|
): PreferencesSettingsPreferencesRequest => ({
|
||||||
|
email_notifications: draft.email,
|
||||||
|
push_notifications: draft.push,
|
||||||
|
marketing_notifications: draft.marketing,
|
||||||
|
telegram_notifications: draft.telegram,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const toPlayerPreferencesPayload = (
|
||||||
|
draft: PlayerSettingsDraft,
|
||||||
|
): PreferencesSettingsPreferencesRequest => ({
|
||||||
|
autoplay: draft.autoplay,
|
||||||
|
loop: draft.loop,
|
||||||
|
muted: draft.muted,
|
||||||
|
show_controls: draft.showControls,
|
||||||
|
pip: draft.pip,
|
||||||
|
airplay: draft.airplay,
|
||||||
|
chromecast: draft.chromecast,
|
||||||
|
});
|
||||||
|
|
||||||
|
export function useSettingsPreferencesQuery() {
|
||||||
|
return useQuery({
|
||||||
|
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
|
||||||
|
query: async () => {
|
||||||
|
const response = await client.settings.preferencesList({ baseUrl: '/r' });
|
||||||
|
return normalizePreferencesSnapshot(response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { client, ContentType } from '@/api/client';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
@@ -12,6 +13,9 @@ export interface QueueItem {
|
|||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
file?: File; // Keep reference to file for local uploads
|
file?: File; // Keep reference to file for local uploads
|
||||||
url?: string; // Keep reference to url for remote uploads
|
url?: string; // Keep reference to url for remote uploads
|
||||||
|
playbackUrl?: string;
|
||||||
|
videoId?: string;
|
||||||
|
mergeId?: string;
|
||||||
// Upload chunk tracking
|
// Upload chunk tracking
|
||||||
activeChunks?: number;
|
activeChunks?: number;
|
||||||
uploadedUrls?: string[];
|
uploadedUrls?: string[];
|
||||||
@@ -299,6 +303,25 @@ export function useUploadQueue() {
|
|||||||
throw new Error(data.error || t('upload.errors.mergeFailed'));
|
throw new Error(data.error || t('upload.errors.mergeFailed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const playbackUrl = data.playback_url || data.play_url;
|
||||||
|
if (!playbackUrl) {
|
||||||
|
throw new Error('Playback URL missing after merge');
|
||||||
|
}
|
||||||
|
|
||||||
|
const createResponse = await client.videos.videosCreate({
|
||||||
|
title: item.file.name.replace(/\.[^.]+$/, ''),
|
||||||
|
description: '',
|
||||||
|
url: playbackUrl,
|
||||||
|
size: item.file.size,
|
||||||
|
duration: 0,
|
||||||
|
format: item.file.type || 'video/mp4',
|
||||||
|
}, { baseUrl: '/r' });
|
||||||
|
|
||||||
|
const createdVideo = (createResponse.data as any)?.data?.video || (createResponse.data as any)?.data;
|
||||||
|
item.videoId = createdVideo?.id;
|
||||||
|
item.mergeId = data.id;
|
||||||
|
item.playbackUrl = playbackUrl;
|
||||||
|
item.url = playbackUrl;
|
||||||
item.status = 'complete';
|
item.status = 'complete';
|
||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
item.uploaded = item.total;
|
item.uploaded = item.total;
|
||||||
|
|||||||
40
src/composables/useUsageQuery.ts
Normal file
40
src/composables/useUsageQuery.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { client } from '@/api/client';
|
||||||
|
import { useQuery } from '@pinia/colada';
|
||||||
|
|
||||||
|
export const USAGE_QUERY_KEY = ['usage'] as const;
|
||||||
|
|
||||||
|
export type UsageSnapshot = {
|
||||||
|
totalVideos: number;
|
||||||
|
totalStorage: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UsageResponse = {
|
||||||
|
data?: {
|
||||||
|
total_videos?: number;
|
||||||
|
total_storage?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
||||||
|
totalVideos: 0,
|
||||||
|
totalStorage: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeUsageSnapshot = (responseData: unknown): UsageSnapshot => {
|
||||||
|
const usage = (responseData as UsageResponse | undefined)?.data;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalVideos: usage?.total_videos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
||||||
|
totalStorage: usage?.total_storage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useUsageQuery() {
|
||||||
|
return useQuery({
|
||||||
|
key: () => USAGE_QUERY_KEY,
|
||||||
|
query: async () => {
|
||||||
|
const response = await client.usage.usageList({ baseUrl: '/r' });
|
||||||
|
return normalizeUsageSnapshot(response.data);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
|
||||||
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
|
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
|
||||||
const backendOptions: HttpBackendOptions = {
|
const backendOptions: HttpBackendOptions = {
|
||||||
loadPath: 'http://localhost:5173/locales/{{lng}}/{{ns}}.json',
|
loadPath: 'http://localhost:5173/locales/{{lng}}/{{ns}}.json',
|
||||||
@@ -27,7 +26,6 @@ const i18n = i18next.createInstance();
|
|||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(I18NextHttpBackend)
|
.use(I18NextHttpBackend)
|
||||||
.use(LanguageDetector)
|
|
||||||
.init({
|
.init({
|
||||||
lng,
|
lng,
|
||||||
supportedLngs: ["en", "vi"],
|
supportedLngs: ["en", "vi"],
|
||||||
@@ -41,4 +39,4 @@ i18n
|
|||||||
});
|
});
|
||||||
return i18n;
|
return i18n;
|
||||||
};
|
};
|
||||||
export default createI18nInstance;
|
export default createI18nInstance;
|
||||||
|
|||||||
71
src/routes/auth/google-finalize.vue
Normal file
71
src/routes/auth/google-finalize.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
|
<div class="i-svg-spinners-90-ring-with-bg h-10 w-10 text-blue-600"></div>
|
||||||
|
<p class="text-sm text-gray-600">{{ message }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const route = useRoute();
|
||||||
|
const router = useRouter();
|
||||||
|
const toast = useAppToast();
|
||||||
|
|
||||||
|
const status = computed(() => String(route.query.status ?? 'error'));
|
||||||
|
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
|
||||||
|
|
||||||
|
const reasonMessages: Record<string, string> = {
|
||||||
|
missing_state: 'Google login session is invalid. Please try again.',
|
||||||
|
invalid_state: 'Google login session has expired. Please try again.',
|
||||||
|
missing_code: 'Google did not return an authorization code.',
|
||||||
|
access_denied: 'Google login was cancelled.',
|
||||||
|
exchange_failed: 'Failed to sign in with Google.',
|
||||||
|
userinfo_failed: 'Failed to load your Google account information.',
|
||||||
|
userinfo_parse_failed: 'Failed to read your Google account information.',
|
||||||
|
missing_email: 'Your Google account did not provide an email address.',
|
||||||
|
create_user_failed: 'Failed to create your account.',
|
||||||
|
update_user_failed: 'Failed to update your account.',
|
||||||
|
reload_user_failed: 'Failed to finish signing you in.',
|
||||||
|
session_failed: 'Failed to create your sign-in session.',
|
||||||
|
fetch_me_failed: 'Signed in with Google, but failed to load your account.',
|
||||||
|
google_login_failed: 'Google login failed. Please try again.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const errorMessage = computed(() => reasonMessages[reason.value] ?? reasonMessages.google_login_failed);
|
||||||
|
const message = computed(() => status.value === 'success' ? 'Signing you in with Google...' : errorMessage.value);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (status.value !== 'success') {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Google login failed',
|
||||||
|
detail: errorMessage.value,
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
await router.replace({ name: 'login', query: { reason: reason.value } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const user = await auth.fetchMe();
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('missing_user');
|
||||||
|
}
|
||||||
|
|
||||||
|
await router.replace({ name: 'overview' });
|
||||||
|
} catch {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: 'Google login failed',
|
||||||
|
detail: 'Signed in with Google, but failed to load your account.',
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
await router.replace({ name: 'login', query: { reason: 'fetch_me_failed' } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
@@ -45,6 +45,11 @@ const content = computed(() => ({
|
|||||||
title: t('auth.layout.forgot.title'),
|
title: t('auth.layout.forgot.title'),
|
||||||
subtitle: t('auth.layout.forgot.subtitle'),
|
subtitle: t('auth.layout.forgot.subtitle'),
|
||||||
headTitle: t('auth.layout.forgot.headTitle')
|
headTitle: t('auth.layout.forgot.headTitle')
|
||||||
|
},
|
||||||
|
'google-auth-finalize': {
|
||||||
|
title: 'Google sign in',
|
||||||
|
subtitle: 'Completing your Google sign in.',
|
||||||
|
headTitle: 'Google sign in - Holistream'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useRouteLoading } from "@/composables/useRouteLoading";
|
||||||
import { useAuthStore } from "@/stores/auth";
|
import { useAuthStore } from "@/stores/auth";
|
||||||
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||||
import { inject } from "vue";
|
import { inject } from "vue";
|
||||||
@@ -68,6 +69,11 @@ const routes: RouteData[] = [
|
|||||||
name: "forgot",
|
name: "forgot",
|
||||||
component: () => import("./auth/forgot.vue"),
|
component: () => import("./auth/forgot.vue"),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "auth/google/finalize",
|
||||||
|
name: "google-auth-finalize",
|
||||||
|
component: () => import("./auth/google-finalize.vue"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -85,16 +91,6 @@ const routes: RouteData[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// path: "upload",
|
|
||||||
// name: "upload",
|
|
||||||
// component: () => import("./upload/Upload.vue"),
|
|
||||||
// meta: {
|
|
||||||
// head: {
|
|
||||||
// title: "Upload - Holistream",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
path: "videos",
|
path: "videos",
|
||||||
children: [
|
children: [
|
||||||
@@ -114,16 +110,6 @@ const routes: RouteData[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// path: ":id",
|
|
||||||
// name: "video-detail",
|
|
||||||
// component: () => import("./video/DetailVideo.vue"),
|
|
||||||
// meta: {
|
|
||||||
// head: {
|
|
||||||
// title: "Edit Video - Holistream",
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -255,16 +241,27 @@ const createAppRouter = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const loading = useRouteLoading()
|
||||||
router.beforeEach((to, from) => {
|
router.beforeEach((to, from) => {
|
||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const head = inject(headSymbol);
|
const head = inject(headSymbol);
|
||||||
(head as any).push(to.meta.head || {});
|
(head as any).push(to.meta.head || {});
|
||||||
|
if (to.fullPath !== from.fullPath && !import.meta.env.SSR) {
|
||||||
|
loading.start()
|
||||||
|
}
|
||||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||||
if (!auth.user) {
|
if (!auth.user) {
|
||||||
return { name: "login" };
|
return { name: "login" };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
router.afterEach(() => {
|
||||||
|
loading.finish()
|
||||||
|
})
|
||||||
|
|
||||||
|
router.onError(() => {
|
||||||
|
loading.fail()
|
||||||
|
})
|
||||||
return router;
|
return router;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,117 +1,49 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import NotificationActions from './components/NotificationActions.vue';
|
import NotificationActions from './components/NotificationActions.vue';
|
||||||
import NotificationList from './components/NotificationList.vue';
|
import NotificationList from './components/NotificationList.vue';
|
||||||
import NotificationTabs from './components/NotificationTabs.vue';
|
import NotificationTabs from './components/NotificationTabs.vue';
|
||||||
|
import { useNotifications } from '@/composables/useNotifications';
|
||||||
|
|
||||||
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
|
||||||
|
|
||||||
interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: NotificationType;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
time: string;
|
|
||||||
read: boolean;
|
|
||||||
actionUrl?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const loading = ref(false);
|
|
||||||
const activeTab = ref('all');
|
const activeTab = ref('all');
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const notificationStore = useNotifications();
|
||||||
|
|
||||||
const notifications = ref<Notification[]>([
|
onMounted(() => {
|
||||||
{
|
void notificationStore.fetchNotifications();
|
||||||
id: '1',
|
});
|
||||||
type: 'video',
|
|
||||||
title: t('notification.mocks.videoProcessed.title'),
|
|
||||||
message: t('notification.mocks.videoProcessed.message'),
|
|
||||||
time: t('notification.time.minutesAgo', { count: 2 }),
|
|
||||||
read: false,
|
|
||||||
actionUrl: '/video',
|
|
||||||
actionLabel: t('notification.actions.viewVideo')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '2',
|
|
||||||
type: 'payment',
|
|
||||||
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: t('notification.actions.viewReceipt')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '3',
|
|
||||||
type: 'warning',
|
|
||||||
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: t('notification.actions.upgradePlan')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: '4',
|
|
||||||
type: 'success',
|
|
||||||
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: 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: t('notification.mocks.newFeature.title'),
|
|
||||||
message: t('notification.mocks.newFeature.message'),
|
|
||||||
time: t('notification.time.daysAgo', { count: 3 }),
|
|
||||||
read: true,
|
|
||||||
actionUrl: '/video',
|
|
||||||
actionLabel: t('notification.actions.tryNow')
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
|
|
||||||
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
||||||
|
|
||||||
const tabs = computed(() => [
|
const tabs = computed(() => [
|
||||||
{ key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notifications.value.length },
|
{ key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notificationStore.notifications.value.length },
|
||||||
{ key: 'unread', label: t('notification.tabs.unread'), icon: 'i-lucide-bell-dot', count: unreadCount.value },
|
{ 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: 'video', label: t('notification.tabs.videos'), icon: 'i-lucide-video', count: notificationStore.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 }
|
{ key: 'payment', label: t('notification.tabs.payments'), icon: 'i-lucide-credit-card', count: notificationStore.notifications.value.filter(n => n.type === 'payment').length },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const filteredNotifications = computed(() => {
|
const filteredNotifications = computed(() => {
|
||||||
if (activeTab.value === 'all') return notifications.value;
|
if (activeTab.value === 'all') return notificationStore.notifications.value;
|
||||||
if (activeTab.value === 'unread') return notifications.value.filter(n => !n.read);
|
if (activeTab.value === 'unread') return notificationStore.notifications.value.filter(n => !n.read);
|
||||||
return notifications.value.filter(n => n.type === activeTab.value);
|
return notificationStore.notifications.value.filter(n => n.type === activeTab.value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMarkRead = (id: string) => {
|
const handleMarkRead = async (id: string) => {
|
||||||
const notification = notifications.value.find(n => n.id === id);
|
await notificationStore.markRead(id);
|
||||||
if (notification) notification.read = true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
notifications.value = notifications.value.filter(n => n.id !== id);
|
await notificationStore.deleteNotification(id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = () => {
|
const handleMarkAllRead = async () => {
|
||||||
notifications.value.forEach(n => n.read = true);
|
await notificationStore.markAllRead();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClearAll = () => {
|
const handleClearAll = async () => {
|
||||||
notifications.value = [];
|
await notificationStore.clearAll();
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -128,8 +60,8 @@ const handleClearAll = () => {
|
|||||||
<div class="w-full max-w-4xl mx-auto mt-6">
|
<div class="w-full max-w-4xl mx-auto mt-6">
|
||||||
<div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
|
<div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
|
||||||
<NotificationActions
|
<NotificationActions
|
||||||
:loading="loading"
|
:loading="notificationStore.loading.value"
|
||||||
:total-count="notifications.length"
|
:total-count="notificationStore.notifications.value.length"
|
||||||
:unread-count="unreadCount"
|
:unread-count="unreadCount"
|
||||||
@mark-all-read="handleMarkAllRead"
|
@mark-all-read="handleMarkAllRead"
|
||||||
@clear-all="handleClearAll"
|
@clear-all="handleClearAll"
|
||||||
@@ -143,7 +75,7 @@ const handleClearAll = () => {
|
|||||||
|
|
||||||
<NotificationList
|
<NotificationList
|
||||||
:notifications="filteredNotifications"
|
:notifications="filteredNotifications"
|
||||||
:loading="loading"
|
:loading="notificationStore.loading.value"
|
||||||
@mark-read="handleMarkRead"
|
@mark-read="handleMarkRead"
|
||||||
@delete="handleDelete"
|
@delete="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,48 +1,42 @@
|
|||||||
<script setup lang="tsx">
|
<script setup lang="tsx">
|
||||||
import { client, type ModelVideo } from '@/api/client';
|
import { client, type ModelVideo } from '@/api/client';
|
||||||
|
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import { onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import NameGradient from './components/NameGradient.vue';
|
import NameGradient from './components/NameGradient.vue';
|
||||||
import QuickActions from './components/QuickActions.vue';
|
import QuickActions from './components/QuickActions.vue';
|
||||||
import RecentVideos from './components/RecentVideos.vue';
|
import RecentVideos from './components/RecentVideos.vue';
|
||||||
import StatsOverview from './components/StatsOverview.vue';
|
import StatsOverview from './components/StatsOverview.vue';
|
||||||
|
|
||||||
const loading = ref(true);
|
const recentVideosLoading = ref(true);
|
||||||
const recentVideos = ref<ModelVideo[]>([]);
|
const recentVideos = ref<ModelVideo[]>([]);
|
||||||
|
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
|
||||||
|
|
||||||
const stats = ref({
|
const stats = computed(() => ({
|
||||||
totalVideos: 0,
|
totalVideos: usageSnapshot.value?.totalVideos ?? 0,
|
||||||
totalViews: 0,
|
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||||
storageUsed: 0,
|
storageUsed: usageSnapshot.value?.totalStorage ?? 0,
|
||||||
storageLimit: 10737418240,
|
storageLimit: 10737418240,
|
||||||
uploadsThisMonth: 0
|
}));
|
||||||
});
|
const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
|
||||||
|
|
||||||
const fetchDashboardData = async () => {
|
const fetchDashboardData = async () => {
|
||||||
loading.value = true;
|
recentVideosLoading.value = true;
|
||||||
try {
|
try {
|
||||||
const response = await client.videos.videosList({ page: 1, limit: 5 });
|
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
|
||||||
const body = response.data as any;
|
const body = response.data as any;
|
||||||
|
|
||||||
if (body.data && Array.isArray(body.data)) {
|
const videos = Array.isArray(body?.data?.videos)
|
||||||
recentVideos.value = body.data;
|
? body.data.videos
|
||||||
stats.value.totalVideos = body.data.length;
|
: Array.isArray(body?.videos)
|
||||||
} else if (Array.isArray(body)) {
|
? body.videos
|
||||||
recentVideos.value = body;
|
: [];
|
||||||
stats.value.totalVideos = body.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
|
recentVideos.value = videos;
|
||||||
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
|
|
||||||
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
|
|
||||||
const uploadDate = new Date(v.created_at || '');
|
|
||||||
const now = new Date();
|
|
||||||
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
|
|
||||||
}).length;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch dashboard data:', err);
|
console.error('Failed to fetch dashboard data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
recentVideosLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -57,11 +51,11 @@ onMounted(() => {
|
|||||||
{ label: $t('pageHeader.dashboard') }
|
{ label: $t('pageHeader.dashboard') }
|
||||||
]" />
|
]" />
|
||||||
|
|
||||||
<StatsOverview :loading="loading" :stats="stats" />
|
<StatsOverview :loading="statsLoading" :stats="stats" />
|
||||||
|
|
||||||
<QuickActions :loading="loading" />
|
<QuickActions :loading="recentVideosLoading" />
|
||||||
|
|
||||||
<RecentVideos :loading="loading" :videos="recentVideos" />
|
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
|
||||||
|
|
||||||
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const quickActions = computed(() => [
|
|||||||
title: t('overview.quickActions.videoLibrary.title'),
|
title: t('overview.quickActions.videoLibrary.title'),
|
||||||
description: t('overview.quickActions.videoLibrary.description'),
|
description: t('overview.quickActions.videoLibrary.description'),
|
||||||
icon: Video,
|
icon: Video,
|
||||||
onClick: () => router.push('/video')
|
onClick: () => router.push('/videos')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('overview.quickActions.analytics.title'),
|
title: t('overview.quickActions.analytics.title'),
|
||||||
@@ -42,7 +42,7 @@ const quickActions = computed(() => [
|
|||||||
title: t('overview.quickActions.managePlan.title'),
|
title: t('overview.quickActions.managePlan.title'),
|
||||||
description: t('overview.quickActions.managePlan.description'),
|
description: t('overview.quickActions.managePlan.description'),
|
||||||
icon: Credit,
|
icon: Credit,
|
||||||
onClick: () => router.push('/payments-and-plans')
|
onClick: () => router.push('/settings/billing')
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import EmptyState from '@/components/dashboard/EmptyState.vue';
|
|||||||
import { formatDate, formatDuration } from '@/lib/utils';
|
import { formatDate, formatDuration } from '@/lib/utils';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
|
import { useUIState } from '@/stores/uiState';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@@ -13,6 +14,7 @@ interface Props {
|
|||||||
defineProps<Props>();
|
defineProps<Props>();
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const uiState = useUIState();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const getStatusClass = (status?: string) => {
|
const getStatusClass = (status?: string) => {
|
||||||
@@ -48,7 +50,7 @@ const getStatusClass = (status?: string) => {
|
|||||||
<div v-else>
|
<div v-else>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||||
<router-link to="/video"
|
<router-link to="/videos"
|
||||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
||||||
{{ t('overview.recentVideos.viewAll') }}
|
{{ t('overview.recentVideos.viewAll') }}
|
||||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||||
@@ -58,7 +60,7 @@ const getStatusClass = (status?: string) => {
|
|||||||
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
|
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
|
||||||
:description="t('overview.recentVideos.emptyDescription')"
|
:description="t('overview.recentVideos.emptyDescription')"
|
||||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
|
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
|
||||||
:onAction="() => router.push('/upload')" />
|
:onAction="() => uiState.toggleUploadDialog()" />
|
||||||
|
|
||||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ interface Props {
|
|||||||
totalViews: number;
|
totalViews: number;
|
||||||
storageUsed: number;
|
storageUsed: number;
|
||||||
storageLimit: number;
|
storageLimit: number;
|
||||||
uploadsThisMonth: number;
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,8 +20,8 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
<div v-for="i in 3" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||||
@@ -33,7 +32,7 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||||
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
<StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
|
||||||
|
|
||||||
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
|
<StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
|
||||||
@@ -41,8 +40,5 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
|||||||
|
|
||||||
<StatsCard :title="t('overview.stats.storageUsed')"
|
<StatsCard :title="t('overview.stats.storageUsed')"
|
||||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||||
|
|
||||||
<StatsCard :title="t('overview.stats.uploadsThisMonth')" :value="stats.uploadsThisMonth" color="success"
|
|
||||||
:trend="{ value: 25, isPositive: true }" />
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { client } from '@/api/client';
|
||||||
import AppButton from '@/components/app/AppButton.vue';
|
import AppButton from '@/components/app/AppButton.vue';
|
||||||
import AppDialog from '@/components/app/AppDialog.vue';
|
import AppDialog from '@/components/app/AppDialog.vue';
|
||||||
import AppInput from '@/components/app/AppInput.vue';
|
import AppInput from '@/components/app/AppInput.vue';
|
||||||
@@ -12,11 +13,15 @@ import { useAppConfirm } from '@/composables/useAppConfirm';
|
|||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import { computed, ref } from 'vue';
|
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { useQuery } from '@pinia/colada';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const confirm = useAppConfirm();
|
const confirm = useAppConfirm();
|
||||||
|
const auth = useAuthStore();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
interface VastTemplate {
|
interface VastTemplate {
|
||||||
@@ -26,39 +31,97 @@ interface VastTemplate {
|
|||||||
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
|
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||||
duration?: number;
|
duration?: number;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
isDefault: boolean;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const templates = ref<VastTemplate[]>([
|
type AdTemplateApiItem = {
|
||||||
{
|
id?: string;
|
||||||
id: '1',
|
name?: string;
|
||||||
name: 'Main Pre-roll Ad',
|
vast_tag_url?: string;
|
||||||
vastUrl: 'https://ads.example.com/vast/pre-roll.xml',
|
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||||
adFormat: 'pre-roll',
|
duration?: number | null;
|
||||||
enabled: true,
|
is_active?: boolean;
|
||||||
createdAt: '2024-01-10',
|
is_default?: boolean;
|
||||||
},
|
created_at?: string;
|
||||||
{
|
};
|
||||||
id: '2',
|
|
||||||
name: 'Mid-roll Ad Break',
|
|
||||||
vastUrl: 'https://ads.example.com/vast/mid-roll.xml',
|
|
||||||
adFormat: 'mid-roll',
|
|
||||||
duration: 30,
|
|
||||||
enabled: false,
|
|
||||||
createdAt: '2024-02-15',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||||
|
|
||||||
const showAddDialog = ref(false);
|
const showAddDialog = ref(false);
|
||||||
const editingTemplate = ref<VastTemplate | null>(null);
|
const editingTemplate = ref<VastTemplate | null>(null);
|
||||||
|
const saving = ref(false);
|
||||||
|
const deletingId = ref<string | null>(null);
|
||||||
|
const togglingId = ref<string | null>(null);
|
||||||
|
const defaultingId = ref<string | null>(null);
|
||||||
|
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
name: '',
|
name: '',
|
||||||
vastUrl: '',
|
vastUrl: '',
|
||||||
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
|
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
|
||||||
duration: undefined as number | undefined,
|
duration: undefined as number | undefined,
|
||||||
|
isDefault: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||||
|
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
|
||||||
|
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
|
||||||
|
|
||||||
|
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
|
||||||
|
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
|
||||||
|
name: item.name || '',
|
||||||
|
vastUrl: item.vast_tag_url || '',
|
||||||
|
adFormat: item.ad_format || 'pre-roll',
|
||||||
|
duration: typeof item.duration === 'number' ? item.duration : undefined,
|
||||||
|
enabled: Boolean(item.is_active),
|
||||||
|
isDefault: Boolean(item.is_default),
|
||||||
|
createdAt: item.created_at || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
|
||||||
|
key: () => ['settings', 'ad-templates'],
|
||||||
|
query: async () => {
|
||||||
|
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||||
|
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const templates = computed(() => templatesSnapshot.value || []);
|
||||||
|
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
|
||||||
|
|
||||||
|
const refetchTemplates = () => refetch((fetchError) => {
|
||||||
|
throw fetchError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
|
||||||
|
|
||||||
|
const showActionErrorToast = (value: any) => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.adsVast.toast.failedSummary'),
|
||||||
|
detail: getErrorMessage(value, t('settings.adsVast.toast.failedDetail')),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const showUpgradeRequiredToast = () => {
|
||||||
|
toast.add({
|
||||||
|
severity: 'warn',
|
||||||
|
summary: t('settings.adsVast.toast.upgradeRequiredSummary'),
|
||||||
|
detail: t('settings.adsVast.toast.upgradeRequiredDetail'),
|
||||||
|
life: 4000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const ensurePaidPlan = () => {
|
||||||
|
if (!isFreePlan.value) return true;
|
||||||
|
showUpgradeRequiredToast();
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(error, (value, previous) => {
|
||||||
|
if (!value || value === previous || isMutating.value) return;
|
||||||
|
showActionErrorToast(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
@@ -67,27 +130,47 @@ const resetForm = () => {
|
|||||||
vastUrl: '',
|
vastUrl: '',
|
||||||
adFormat: 'pre-roll',
|
adFormat: 'pre-roll',
|
||||||
duration: undefined,
|
duration: undefined,
|
||||||
|
isDefault: false,
|
||||||
};
|
};
|
||||||
editingTemplate.value = null;
|
editingTemplate.value = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const closeDialog = () => {
|
||||||
|
showAddDialog.value = false;
|
||||||
|
resetForm();
|
||||||
|
};
|
||||||
|
|
||||||
const openAddDialog = () => {
|
const openAddDialog = () => {
|
||||||
|
if (!ensurePaidPlan()) return;
|
||||||
resetForm();
|
resetForm();
|
||||||
showAddDialog.value = true;
|
showAddDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEditDialog = (template: VastTemplate) => {
|
const openEditDialog = (template: VastTemplate) => {
|
||||||
|
if (!ensurePaidPlan()) return;
|
||||||
formData.value = {
|
formData.value = {
|
||||||
name: template.name,
|
name: template.name,
|
||||||
vastUrl: template.vastUrl,
|
vastUrl: template.vastUrl,
|
||||||
adFormat: template.adFormat,
|
adFormat: template.adFormat,
|
||||||
duration: template.duration,
|
duration: template.duration,
|
||||||
|
isDefault: template.isDefault,
|
||||||
};
|
};
|
||||||
editingTemplate.value = template;
|
editingTemplate.value = template;
|
||||||
showAddDialog.value = true;
|
showAddDialog.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
const buildRequestBody = (enabled = true) => ({
|
||||||
|
name: formData.value.name.trim(),
|
||||||
|
vast_tag_url: formData.value.vastUrl.trim(),
|
||||||
|
ad_format: formData.value.adFormat,
|
||||||
|
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||||
|
is_active: enabled,
|
||||||
|
is_default: enabled ? formData.value.isDefault : false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (saving.value || !ensurePaidPlan()) return;
|
||||||
|
|
||||||
if (!formData.value.name.trim()) {
|
if (!formData.value.name.trim()) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -117,7 +200,7 @@ const handleSave = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
|
if (formData.value.adFormat === 'mid-roll' && (!formData.value.duration || formData.value.duration <= 0)) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('settings.adsVast.toast.durationRequiredSummary'),
|
summary: t('settings.adsVast.toast.durationRequiredSummary'),
|
||||||
@@ -127,74 +210,146 @@ const handleSave = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (editingTemplate.value) {
|
saving.value = true;
|
||||||
const index = templates.value.findIndex(template => template.id === editingTemplate.value!.id);
|
try {
|
||||||
if (index !== -1) {
|
if (editingTemplate.value) {
|
||||||
templates.value[index] = { ...templates.value[index], ...formData.value };
|
await client.adTemplates.adTemplatesUpdate(
|
||||||
|
editingTemplate.value.id,
|
||||||
|
buildRequestBody(editingTemplate.value.enabled),
|
||||||
|
{ baseUrl: '/r' },
|
||||||
|
);
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||||
|
detail: t('settings.adsVast.toast.updatedDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.adsVast.toast.createdSummary'),
|
||||||
|
detail: t('settings.adsVast.toast.createdDetail'),
|
||||||
|
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),
|
|
||||||
...formData.value,
|
|
||||||
enabled: true,
|
|
||||||
createdAt: new Date().toISOString().split('T')[0],
|
|
||||||
});
|
|
||||||
toast.add({
|
|
||||||
severity: 'success',
|
|
||||||
summary: t('settings.adsVast.toast.createdSummary'),
|
|
||||||
detail: t('settings.adsVast.toast.createdDetail'),
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
showAddDialog.value = false;
|
await refetchTemplates();
|
||||||
resetForm();
|
closeDialog();
|
||||||
|
} catch (value: any) {
|
||||||
|
console.error(value);
|
||||||
|
showActionErrorToast(value);
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggle = (template: VastTemplate) => {
|
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||||
template.enabled = !template.enabled;
|
if (!ensurePaidPlan()) return;
|
||||||
toast.add({
|
|
||||||
severity: 'info',
|
togglingId.value = template.id;
|
||||||
summary: template.enabled
|
try {
|
||||||
? t('settings.adsVast.toast.enabledSummary')
|
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||||
: t('settings.adsVast.toast.disabledSummary'),
|
|
||||||
detail: t('settings.adsVast.toast.toggleDetail', {
|
|
||||||
name: template.name,
|
name: template.name,
|
||||||
state: template.enabled
|
vast_tag_url: template.vastUrl,
|
||||||
? t('settings.adsVast.state.enabled')
|
ad_format: template.adFormat,
|
||||||
: t('settings.adsVast.state.disabled'),
|
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||||
}),
|
is_active: nextValue,
|
||||||
life: 2000,
|
is_default: nextValue ? template.isDefault : false,
|
||||||
});
|
}, { baseUrl: '/r' });
|
||||||
|
|
||||||
|
await refetchTemplates();
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: nextValue
|
||||||
|
? t('settings.adsVast.toast.enabledSummary')
|
||||||
|
: t('settings.adsVast.toast.disabledSummary'),
|
||||||
|
detail: t('settings.adsVast.toast.toggleDetail', {
|
||||||
|
name: template.name,
|
||||||
|
state: nextValue
|
||||||
|
? t('settings.adsVast.state.enabled')
|
||||||
|
: t('settings.adsVast.state.disabled'),
|
||||||
|
}),
|
||||||
|
life: 2000,
|
||||||
|
});
|
||||||
|
} catch (value: any) {
|
||||||
|
console.error(value);
|
||||||
|
showActionErrorToast(value);
|
||||||
|
} finally {
|
||||||
|
togglingId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSetDefault = async (template: VastTemplate) => {
|
||||||
|
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
|
||||||
|
|
||||||
|
defaultingId.value = template.id;
|
||||||
|
try {
|
||||||
|
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||||
|
name: template.name,
|
||||||
|
vast_tag_url: template.vastUrl,
|
||||||
|
ad_format: template.adFormat,
|
||||||
|
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||||
|
is_active: template.enabled,
|
||||||
|
is_default: true,
|
||||||
|
}, { baseUrl: '/r' });
|
||||||
|
|
||||||
|
await refetchTemplates();
|
||||||
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
|
||||||
|
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} catch (value: any) {
|
||||||
|
console.error(value);
|
||||||
|
showActionErrorToast(value);
|
||||||
|
} finally {
|
||||||
|
defaultingId.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (template: VastTemplate) => {
|
const handleDelete = (template: VastTemplate) => {
|
||||||
|
if (!ensurePaidPlan()) return;
|
||||||
|
|
||||||
confirm.require({
|
confirm.require({
|
||||||
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
|
message: t('settings.adsVast.confirm.deleteMessage', { name: template.name }),
|
||||||
header: t('settings.adsVast.confirm.deleteHeader'),
|
header: t('settings.adsVast.confirm.deleteHeader'),
|
||||||
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
|
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
|
||||||
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
|
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
|
||||||
accept: () => {
|
accept: async () => {
|
||||||
const index = templates.value.findIndex(item => item.id === template.id);
|
deletingId.value = template.id;
|
||||||
if (index !== -1) templates.value.splice(index, 1);
|
try {
|
||||||
toast.add({
|
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
|
||||||
severity: 'info',
|
await refetchTemplates();
|
||||||
summary: t('settings.adsVast.toast.deletedSummary'),
|
toast.add({
|
||||||
detail: t('settings.adsVast.toast.deletedDetail'),
|
severity: 'info',
|
||||||
life: 3000,
|
summary: t('settings.adsVast.toast.deletedSummary'),
|
||||||
});
|
detail: t('settings.adsVast.toast.deletedDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} catch (value: any) {
|
||||||
|
console.error(value);
|
||||||
|
showActionErrorToast(value);
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyToClipboard = (text: string) => {
|
const copyToClipboard = async (text: string) => {
|
||||||
navigator.clipboard.writeText(text);
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
} catch {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.adsVast.toast.copiedSummary'),
|
summary: t('settings.adsVast.toast.copiedSummary'),
|
||||||
@@ -228,7 +383,7 @@ const getAdFormatColor = (format: string) => {
|
|||||||
bodyClass=""
|
bodyClass=""
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<AppButton size="sm" @click="openAddDialog">
|
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusIcon class="w-4 h-4" />
|
<PlusIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -240,7 +395,19 @@ const getAdFormatColor = (format: string) => {
|
|||||||
{{ t('settings.adsVast.infoBanner') }}
|
{{ t('settings.adsVast.infoBanner') }}
|
||||||
</SettingsNotice>
|
</SettingsNotice>
|
||||||
|
|
||||||
<div class="border-b border-border mt-4">
|
<SettingsNotice
|
||||||
|
v-if="isFreePlan"
|
||||||
|
tone="warning"
|
||||||
|
:title="t('settings.adsVast.readOnlyTitle')"
|
||||||
|
class="rounded-none border-x-0 border-t-0 p-3"
|
||||||
|
contentClass="text-xs text-foreground/70"
|
||||||
|
>
|
||||||
|
{{ t('settings.adsVast.readOnlyMessage') }}
|
||||||
|
</SettingsNotice>
|
||||||
|
|
||||||
|
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||||
|
|
||||||
|
<div v-else class="border-b border-border mt-4">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-muted/30">
|
<thead class="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -252,57 +419,84 @@ const getAdFormatColor = (format: string) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="divide-y divide-border">
|
||||||
<tr
|
<template v-if="templates.length > 0">
|
||||||
v-for="template in templates"
|
<tr
|
||||||
:key="template.id"
|
v-for="template in templates"
|
||||||
class="hover:bg-muted/30 transition-all"
|
:key="template.id"
|
||||||
>
|
class="hover:bg-muted/30 transition-all"
|
||||||
<td class="px-6 py-3">
|
>
|
||||||
<div>
|
<td class="px-6 py-3">
|
||||||
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
<div>
|
||||||
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt }) }}</p>
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
</div>
|
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
||||||
</td>
|
<span
|
||||||
<td class="px-6 py-3">
|
v-if="template.isDefault"
|
||||||
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||||
{{ getAdFormatLabel(template.adFormat) }}
|
>
|
||||||
</span>
|
{{ t('settings.adsVast.defaultBadge') }}
|
||||||
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
</span>
|
||||||
({{ template.duration }}s)
|
</div>
|
||||||
</span>
|
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt || '-' }) }}</p>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-6 py-3">
|
</td>
|
||||||
<div class="flex items-center gap-2 max-w-[200px]">
|
<td class="px-6 py-3">
|
||||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
||||||
<AppButton variant="ghost" size="sm" @click="copyToClipboard(template.vastUrl)">
|
{{ getAdFormatLabel(template.adFormat) }}
|
||||||
<template #icon>
|
</span>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
||||||
</template>
|
({{ template.duration }}s)
|
||||||
</AppButton>
|
</span>
|
||||||
</div>
|
</td>
|
||||||
</td>
|
<td class="px-6 py-3">
|
||||||
<td class="px-6 py-3 text-center">
|
<div class="flex items-center gap-2 max-w-[240px]">
|
||||||
<AppSwitch
|
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||||
:model-value="template.enabled"
|
<AppButton variant="ghost" size="sm" :disabled="isMutating" @click="copyToClipboard(template.vastUrl)">
|
||||||
@update:model-value="handleToggle(template)"
|
<template #icon>
|
||||||
/>
|
<CheckIcon class="w-4 h-4" />
|
||||||
</td>
|
</template>
|
||||||
<td class="px-6 py-3 text-right">
|
</AppButton>
|
||||||
<div class="flex items-center justify-end gap-1">
|
</div>
|
||||||
<AppButton variant="ghost" size="sm" @click="openEditDialog(template)">
|
</td>
|
||||||
<template #icon>
|
<td class="px-6 py-3 text-center">
|
||||||
<PencilIcon class="w-4 h-4" />
|
<AppSwitch
|
||||||
</template>
|
:model-value="template.enabled"
|
||||||
</AppButton>
|
:disabled="isFreePlan || saving || deletingId !== null || defaultingId !== null || togglingId === template.id"
|
||||||
<AppButton variant="ghost" size="sm" @click="handleDelete(template)">
|
@update:model-value="handleToggle(template, $event)"
|
||||||
<template #icon>
|
/>
|
||||||
<TrashIcon class="w-4 h-4 text-danger" />
|
</td>
|
||||||
</template>
|
<td class="px-6 py-3 text-right">
|
||||||
</AppButton>
|
<div class="flex items-center justify-end gap-2 flex-wrap">
|
||||||
</div>
|
<span
|
||||||
</td>
|
v-if="template.isDefault"
|
||||||
</tr>
|
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||||
<tr v-if="templates.length === 0">
|
>
|
||||||
|
{{ t('settings.adsVast.actions.default') }}
|
||||||
|
</span>
|
||||||
|
<AppButton
|
||||||
|
v-else
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
:loading="defaultingId === template.id"
|
||||||
|
:disabled="isFreePlan || saving || deletingId !== null || togglingId !== null || defaultingId !== null || !template.enabled"
|
||||||
|
@click="handleSetDefault(template)"
|
||||||
|
>
|
||||||
|
{{ t('settings.adsVast.actions.setDefault') }}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="openEditDialog(template)">
|
||||||
|
<template #icon>
|
||||||
|
<PencilIcon class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
</AppButton>
|
||||||
|
<AppButton variant="ghost" size="sm" :disabled="isFreePlan || isMutating" @click="handleDelete(template)">
|
||||||
|
<template #icon>
|
||||||
|
<TrashIcon class="w-4 h-4 text-danger" />
|
||||||
|
</template>
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else>
|
||||||
<td colspan="5" class="px-6 py-12 text-center">
|
<td colspan="5" class="px-6 py-12 text-center">
|
||||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.adsVast.emptyTitle') }}</p>
|
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.adsVast.emptyTitle') }}</p>
|
||||||
@@ -315,9 +509,10 @@ const getAdFormatColor = (format: string) => {
|
|||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="showAddDialog"
|
:visible="showAddDialog"
|
||||||
@update:visible="showAddDialog = $event"
|
|
||||||
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
|
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
|
||||||
maxWidthClass="max-w-lg"
|
maxWidthClass="max-w-lg"
|
||||||
|
@update:visible="showAddDialog = $event"
|
||||||
|
@close="closeDialog"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -325,6 +520,7 @@ const getAdFormatColor = (format: string) => {
|
|||||||
<AppInput
|
<AppInput
|
||||||
id="name"
|
id="name"
|
||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
|
:disabled="isFreePlan || saving"
|
||||||
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -334,6 +530,7 @@ const getAdFormatColor = (format: string) => {
|
|||||||
<AppInput
|
<AppInput
|
||||||
id="vastUrl"
|
id="vastUrl"
|
||||||
v-model="formData.vastUrl"
|
v-model="formData.vastUrl"
|
||||||
|
:disabled="isFreePlan || saving"
|
||||||
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -344,13 +541,16 @@ const getAdFormatColor = (format: string) => {
|
|||||||
<button
|
<button
|
||||||
v-for="format in adFormatOptions"
|
v-for="format in adFormatOptions"
|
||||||
:key="format"
|
:key="format"
|
||||||
@click="formData.adFormat = format"
|
type="button"
|
||||||
|
:disabled="isFreePlan || saving"
|
||||||
:class="[
|
:class="[
|
||||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all',
|
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
|
||||||
formData.adFormat === format
|
formData.adFormat === format
|
||||||
? 'border-primary bg-primary/5 text-primary'
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
: 'border-border text-foreground/60 hover:border-primary/50'
|
: 'border-border text-foreground/60 hover:border-primary/50'
|
||||||
]">
|
]"
|
||||||
|
@click="formData.adFormat = format"
|
||||||
|
>
|
||||||
{{ getAdFormatLabel(format) }}
|
{{ getAdFormatLabel(format) }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,20 +561,46 @@ const getAdFormatColor = (format: string) => {
|
|||||||
<AppInput
|
<AppInput
|
||||||
id="duration"
|
id="duration"
|
||||||
v-model.number="formData.duration"
|
v-model.number="formData.duration"
|
||||||
|
:disabled="isFreePlan || saving"
|
||||||
type="number"
|
type="number"
|
||||||
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
||||||
:min="10"
|
:min="10"
|
||||||
:max="600"
|
:max="600"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.defaultLabel') }}</label>
|
||||||
|
<label
|
||||||
|
:class="[
|
||||||
|
'flex items-start gap-3 rounded-md border border-border p-3',
|
||||||
|
canMarkAsDefaultInDialog && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
v-model="formData.isDefault"
|
||||||
|
type="checkbox"
|
||||||
|
class="mt-1 h-4 w-4 rounded border-border"
|
||||||
|
:disabled="!canMarkAsDefaultInDialog || saving"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
|
||||||
|
<p class="text-xs text-foreground/60 mt-0.5">
|
||||||
|
{{ editingTemplate && !editingTemplate.enabled
|
||||||
|
? t('settings.adsVast.dialog.defaultDisabledHint')
|
||||||
|
: t('settings.adsVast.dialog.defaultHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="flex justify-end gap-2">
|
<div class="flex justify-end gap-2">
|
||||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
<AppButton variant="secondary" size="sm" :disabled="saving" @click="closeDialog">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton size="sm" @click="handleSave">
|
<AppButton size="sm" :loading="saving" :disabled="isFreePlan" @click="handleSave">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<CheckIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { client, type ModelPlan } from '@/api/client';
|
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 { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
|
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
|
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
|
||||||
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
|
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
|
||||||
@@ -10,23 +14,36 @@ import BillingWalletRow from '@/routes/settings/components/billing/BillingWallet
|
|||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useQuery } from '@pinia/colada';
|
import { useQuery } from '@pinia/colada';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
|
|
||||||
const toast = useAppToast();
|
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||||
const auth = useAuthStore();
|
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||||
const { t, i18next } = useTranslation();
|
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
type PlansEnvelope = {
|
||||||
key: () => ['payments-and-plans'],
|
data?: {
|
||||||
query: () => client.plans.plansList(),
|
plans?: ModelPlan[];
|
||||||
});
|
} | ModelPlan[];
|
||||||
|
};
|
||||||
|
|
||||||
const subscribing = ref<string | null>(null);
|
type PaymentHistoryApiItem = {
|
||||||
|
id?: string;
|
||||||
|
amount?: number;
|
||||||
|
currency?: string;
|
||||||
|
status?: string;
|
||||||
|
plan_name?: string;
|
||||||
|
invoice_id?: string;
|
||||||
|
kind?: string;
|
||||||
|
term_months?: number;
|
||||||
|
payment_method?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
const topupDialogVisible = ref(false);
|
type PaymentHistoryEnvelope = {
|
||||||
const topupAmount = ref<number | null>(0);
|
data?: {
|
||||||
const topupLoading = ref(false);
|
payments?: PaymentHistoryApiItem[];
|
||||||
const topupPresets = [10, 20, 50, 100];
|
};
|
||||||
|
};
|
||||||
|
|
||||||
type PaymentHistoryItem = {
|
type PaymentHistoryItem = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -35,44 +52,108 @@ type PaymentHistoryItem = {
|
|||||||
plan: string;
|
plan: string;
|
||||||
status: string;
|
status: string;
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
|
currency: string;
|
||||||
|
kind: string;
|
||||||
|
details?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const paymentHistory = ref<PaymentHistoryItem[]>([
|
type ApiErrorPayload = {
|
||||||
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
|
code?: number;
|
||||||
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
|
message?: string;
|
||||||
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
|
data?: Record<string, any>;
|
||||||
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
|
};
|
||||||
]);
|
|
||||||
|
|
||||||
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
const toast = useAppToast();
|
||||||
const storageLimit = computed(() => 10737418240);
|
const auth = useAuthStore();
|
||||||
const uploadsUsed = ref(12);
|
const { t, i18next } = useTranslation();
|
||||||
const uploadsLimit = ref(50);
|
|
||||||
|
|
||||||
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
const { data: plansResponse, isLoading } = useQuery({
|
||||||
|
key: () => ['billing-plans'],
|
||||||
|
query: () => client.plans.plansList({ baseUrl: '/r' }),
|
||||||
|
});
|
||||||
|
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
||||||
|
|
||||||
const currentPlanId = computed(() => {
|
const topupDialogVisible = ref(false);
|
||||||
if (auth.user?.plan_id) return auth.user.plan_id;
|
const topupAmount = ref<number | null>(null);
|
||||||
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
|
const topupLoading = ref(false);
|
||||||
return undefined;
|
const historyLoading = ref(false);
|
||||||
|
const downloadingInvoiceId = ref<string | null>(null);
|
||||||
|
const topupPresets = [10, 20, 50, 100];
|
||||||
|
const paymentHistory = ref<PaymentHistoryItem[]>([]);
|
||||||
|
|
||||||
|
const upgradeDialogVisible = ref(false);
|
||||||
|
const selectedPlan = ref<ModelPlan | null>(null);
|
||||||
|
const selectedTermMonths = ref<number>(1);
|
||||||
|
const selectedPaymentMethod = ref<UpgradePaymentMethod>('wallet');
|
||||||
|
const purchaseTopupAmount = ref<number | null>(null);
|
||||||
|
const purchaseLoading = ref(false);
|
||||||
|
const purchaseError = ref<string | null>(null);
|
||||||
|
|
||||||
|
const plans = computed(() => {
|
||||||
|
const body = plansResponse.value?.data as PlansEnvelope | undefined;
|
||||||
|
const payload = body?.data;
|
||||||
|
|
||||||
|
if (Array.isArray(payload)) return payload;
|
||||||
|
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
|
||||||
|
return payload.plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [] as ModelPlan[];
|
||||||
});
|
});
|
||||||
|
|
||||||
const plans = computed(() => data.value?.data?.data.plans || []);
|
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||||
|
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
||||||
|
const currentPlanName = computed(() => currentPlan.value?.name || t('settings.billing.unknownPlan'));
|
||||||
|
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
|
||||||
|
const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
||||||
|
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
||||||
|
const storageLimit = computed(() => {
|
||||||
|
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||||
|
return activePlan?.storage_limit || 10737418240;
|
||||||
|
});
|
||||||
|
const uploadsLimit = computed(() => {
|
||||||
|
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||||
|
return activePlan?.upload_limit || 50;
|
||||||
|
});
|
||||||
const storagePercentage = computed(() =>
|
const storagePercentage = computed(() =>
|
||||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
|
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
||||||
);
|
);
|
||||||
const uploadsPercentage = computed(() =>
|
const uploadsPercentage = computed(() =>
|
||||||
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
|
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100),
|
||||||
);
|
);
|
||||||
|
|
||||||
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
|
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
|
||||||
|
|
||||||
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
|
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
|
||||||
style: 'currency',
|
style: 'currency',
|
||||||
currency: 'USD',
|
currency: 'USD',
|
||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
}));
|
}));
|
||||||
|
const shortDateFormatter = computed(() => new Intl.DateTimeFormat(localeTag.value, {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
|
||||||
|
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
|
||||||
|
const selectedTotalAmount = computed(() => selectedPlanPrice.value * selectedTermMonths.value);
|
||||||
|
const selectedShortfall = computed(() => Math.max(selectedTotalAmount.value - walletBalance.value, 0));
|
||||||
|
const selectedNeedsTopup = computed(() => selectedShortfall.value > 0.000001);
|
||||||
|
const canSubmitUpgrade = computed(() => {
|
||||||
|
if (!selectedPlan.value?.id || purchaseLoading.value) return false;
|
||||||
|
if (!selectedNeedsTopup.value) return true;
|
||||||
|
if (selectedPaymentMethod.value !== 'topup') return false;
|
||||||
|
return (purchaseTopupAmount.value || 0) >= selectedShortfall.value && (purchaseTopupAmount.value || 0) > 0;
|
||||||
|
});
|
||||||
|
const upgradeSubmitLabel = computed(() => {
|
||||||
|
if (selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup') {
|
||||||
|
return t('settings.billing.upgradeDialog.topupAndUpgrade');
|
||||||
|
}
|
||||||
|
|
||||||
|
return t('settings.billing.upgradeDialog.payWithWallet');
|
||||||
|
});
|
||||||
|
|
||||||
|
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
||||||
|
|
||||||
const formatBytes = (bytes: number) => {
|
const formatBytes = (bytes: number) => {
|
||||||
if (bytes === 0) return '0 B';
|
if (bytes === 0) return '0 B';
|
||||||
@@ -85,9 +166,33 @@ const formatBytes = (bytes: number) => {
|
|||||||
|
|
||||||
const formatDuration = (seconds?: number) => {
|
const formatDuration = (seconds?: number) => {
|
||||||
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
|
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
|
||||||
|
if (seconds < 0) return t('settings.billing.durationMinutes', { minutes: -1 }).replace("-1", "∞")
|
||||||
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
|
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatHistoryDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '-';
|
||||||
|
return shortDateFormatter.value.format(date);
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
|
||||||
|
|
||||||
|
const formatPaymentMethodLabel = (value?: string) => {
|
||||||
|
switch ((value || '').toLowerCase()) {
|
||||||
|
case 'topup':
|
||||||
|
return t('settings.billing.paymentMethod.topup');
|
||||||
|
case 'wallet':
|
||||||
|
default:
|
||||||
|
return t('settings.billing.paymentMethod.wallet');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||||
|
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||||
|
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
|
||||||
|
|
||||||
const getStatusStyles = (status: string) => {
|
const getStatusStyles = (status: string) => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'success':
|
case 'success':
|
||||||
@@ -110,52 +215,274 @@ const getStatusLabel = (status: string) => {
|
|||||||
return map[status] || status;
|
return map[status] || status;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
const normalizeHistoryStatus = (status?: string) => {
|
||||||
|
switch ((status || '').toLowerCase()) {
|
||||||
|
case 'success':
|
||||||
|
case 'succeeded':
|
||||||
|
case 'paid':
|
||||||
|
return 'success';
|
||||||
|
case 'failed':
|
||||||
|
case 'error':
|
||||||
|
case 'canceled':
|
||||||
|
case 'cancelled':
|
||||||
|
return 'failed';
|
||||||
|
case 'pending':
|
||||||
|
case 'processing':
|
||||||
|
default:
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
const getApiErrorPayload = (error: unknown): ApiErrorPayload | null => {
|
||||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
if (!error || typeof error !== 'object') return null;
|
||||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit });
|
const candidate = error as { error?: ApiErrorPayload; data?: ApiErrorPayload; message?: string };
|
||||||
|
|
||||||
const subscribe = async (plan: ModelPlan) => {
|
if (candidate.error && typeof candidate.error === 'object') return candidate.error;
|
||||||
if (!plan.id) return;
|
if (candidate.data && typeof candidate.data === 'object') return candidate.data;
|
||||||
subscribing.value = plan.id;
|
if (candidate.message) return { message: candidate.message };
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiErrorMessage = (error: unknown, fallback: string) => {
|
||||||
|
const payload = getApiErrorPayload(error);
|
||||||
|
return payload?.message || fallback;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
|
||||||
|
|
||||||
|
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||||
|
const details: string[] = [];
|
||||||
|
|
||||||
|
if (item.kind !== 'wallet_topup' && item.term_months) {
|
||||||
|
details.push(formatTermLabel(item.term_months));
|
||||||
|
}
|
||||||
|
if (item.kind !== 'wallet_topup' && item.payment_method) {
|
||||||
|
details.push(formatPaymentMethodLabel(item.payment_method));
|
||||||
|
}
|
||||||
|
if (item.kind !== 'wallet_topup' && item.expires_at) {
|
||||||
|
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id || '',
|
||||||
|
date: formatHistoryDate(item.created_at),
|
||||||
|
amount: item.amount || 0,
|
||||||
|
plan: item.kind === 'wallet_topup'
|
||||||
|
? t('settings.billing.walletTopup')
|
||||||
|
: (item.plan_name || t('settings.billing.unknownPlan')),
|
||||||
|
status: normalizeHistoryStatus(item.status),
|
||||||
|
invoiceId: item.invoice_id || '-',
|
||||||
|
currency: item.currency || 'USD',
|
||||||
|
kind: item.kind || 'subscription',
|
||||||
|
details,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadPaymentHistory = async () => {
|
||||||
|
historyLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await client.payments.paymentsCreate({
|
const response = await client.payments.historyList({ baseUrl: '/r' });
|
||||||
amount: plan.price || 0,
|
const body = response.data as PaymentHistoryEnvelope | undefined;
|
||||||
plan_id: plan.id,
|
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
|
||||||
});
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
paymentHistory.value = [];
|
||||||
|
} finally {
|
||||||
|
historyLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refetchUsageSnapshot = () => refetchUsage((fetchError) => {
|
||||||
|
throw fetchError;
|
||||||
|
});
|
||||||
|
|
||||||
|
const refreshBillingState = async () => {
|
||||||
|
await Promise.allSettled([
|
||||||
|
auth.fetchMe(),
|
||||||
|
loadPaymentHistory(),
|
||||||
|
refetchUsageSnapshot(),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
void loadPaymentHistory();
|
||||||
|
|
||||||
|
const subscriptionSummary = computed(() => {
|
||||||
|
const expiresAt = auth.user?.plan_expires_at;
|
||||||
|
const formattedDate = formatHistoryDate(expiresAt);
|
||||||
|
|
||||||
|
if (auth.user?.plan_id) {
|
||||||
|
if (auth.user?.plan_expiring_soon && expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.expiringTitle'),
|
||||||
|
description: t('settings.billing.subscription.expiringDescription', {
|
||||||
|
plan: currentPlanName.value,
|
||||||
|
date: formattedDate,
|
||||||
|
}),
|
||||||
|
tone: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.activeTitle'),
|
||||||
|
description: t('settings.billing.subscription.activeDescription', {
|
||||||
|
plan: currentPlanName.value,
|
||||||
|
date: formattedDate,
|
||||||
|
}),
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.activeTitle'),
|
||||||
|
description: currentPlanName.value,
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expiresAt) {
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.expiredTitle'),
|
||||||
|
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
|
||||||
|
tone: 'warning' as const,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: t('settings.billing.subscription.freeTitle'),
|
||||||
|
description: t('settings.billing.subscription.freeDescription'),
|
||||||
|
tone: 'default' as const,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const resetUpgradeState = () => {
|
||||||
|
selectedPlan.value = null;
|
||||||
|
selectedTermMonths.value = 1;
|
||||||
|
selectedPaymentMethod.value = 'wallet';
|
||||||
|
purchaseTopupAmount.value = null;
|
||||||
|
purchaseError.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openUpgradeDialog = (plan: ModelPlan) => {
|
||||||
|
selectedPlan.value = plan;
|
||||||
|
selectedTermMonths.value = 1;
|
||||||
|
purchaseError.value = null;
|
||||||
|
selectedPaymentMethod.value = walletBalance.value >= (plan.price || 0) ? 'wallet' : 'topup';
|
||||||
|
purchaseTopupAmount.value = null;
|
||||||
|
upgradeDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeUpgradeDialog = () => {
|
||||||
|
if (purchaseLoading.value) return;
|
||||||
|
upgradeDialogVisible.value = false;
|
||||||
|
resetUpgradeState();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUpgradeDialogVisibilityChange = (visible: boolean) => {
|
||||||
|
if (visible) {
|
||||||
|
upgradeDialogVisible.value = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeUpgradeDialog();
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(selectedShortfall, (value) => {
|
||||||
|
if (!upgradeDialogVisible.value) return;
|
||||||
|
|
||||||
|
if (value <= 0) {
|
||||||
|
selectedPaymentMethod.value = 'wallet';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedPaymentMethod.value === 'topup' && ((purchaseTopupAmount.value || 0) < value)) {
|
||||||
|
purchaseTopupAmount.value = Number(value.toFixed(2));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectUpgradePaymentMethod = (method: UpgradePaymentMethod) => {
|
||||||
|
selectedPaymentMethod.value = method;
|
||||||
|
purchaseError.value = null;
|
||||||
|
|
||||||
|
if (method === 'topup' && selectedShortfall.value > 0 && ((purchaseTopupAmount.value || 0) < selectedShortfall.value)) {
|
||||||
|
purchaseTopupAmount.value = Number(selectedShortfall.value.toFixed(2));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatePurchaseTopupAmount = (value: string | number | null) => {
|
||||||
|
if (typeof value === 'number' || value === null) {
|
||||||
|
purchaseTopupAmount.value = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === '') {
|
||||||
|
purchaseTopupAmount.value = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number(value);
|
||||||
|
purchaseTopupAmount.value = Number.isNaN(parsed) ? null : parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitUpgrade = async () => {
|
||||||
|
if (!selectedPlan.value?.id) return;
|
||||||
|
|
||||||
|
purchaseLoading.value = true;
|
||||||
|
purchaseError.value = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
plan_id: selectedPlan.value.id,
|
||||||
|
term_months: selectedTermMonths.value,
|
||||||
|
payment_method: paymentMethod,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (paymentMethod === 'topup') {
|
||||||
|
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
|
||||||
|
await refreshBillingState();
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
|
||||||
detail: t('settings.billing.toast.subscriptionSuccessDetail', { plan: plan.name || '' }),
|
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
|
||||||
|
plan: selectedPlan.value.name || '',
|
||||||
|
term: formatTermLabel(selectedTermMonths.value),
|
||||||
|
}),
|
||||||
life: 3000,
|
life: 3000,
|
||||||
});
|
});
|
||||||
|
|
||||||
paymentHistory.value.unshift({
|
closeUpgradeDialog();
|
||||||
id: `inv_${Date.now()}`,
|
} catch (error) {
|
||||||
date: new Date().toLocaleDateString(localeTag.value, { month: 'short', day: 'numeric', year: 'numeric' }),
|
console.error(error);
|
||||||
amount: plan.price || 0,
|
|
||||||
plan: plan.name || t('settings.billing.unknownPlan'),
|
const errorData = getApiErrorData(error);
|
||||||
status: 'success',
|
const nextShortfall = typeof errorData?.shortfall === 'number'
|
||||||
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`,
|
? errorData.shortfall
|
||||||
});
|
: selectedShortfall.value;
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
if (nextShortfall > 0) {
|
||||||
toast.add({
|
selectedPaymentMethod.value = 'topup';
|
||||||
severity: 'error',
|
if ((purchaseTopupAmount.value || 0) < nextShortfall) {
|
||||||
summary: t('settings.billing.toast.subscriptionFailedSummary'),
|
purchaseTopupAmount.value = Number(nextShortfall.toFixed(2));
|
||||||
detail: err.message || t('settings.billing.toast.subscriptionFailedDetail'),
|
}
|
||||||
life: 5000,
|
}
|
||||||
});
|
|
||||||
|
purchaseError.value = getApiErrorMessage(error, t('settings.billing.toast.subscriptionFailedDetail'));
|
||||||
} finally {
|
} finally {
|
||||||
subscribing.value = null;
|
purchaseLoading.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleTopup = async (amount: number) => {
|
const handleTopup = async (amount: number) => {
|
||||||
topupLoading.value = true;
|
topupLoading.value = true;
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
|
||||||
|
await refreshBillingState();
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
@@ -165,11 +492,12 @@ const handleTopup = async (amount: number) => {
|
|||||||
});
|
});
|
||||||
topupDialogVisible.value = false;
|
topupDialogVisible.value = false;
|
||||||
topupAmount.value = null;
|
topupAmount.value = null;
|
||||||
} catch (e: any) {
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('settings.billing.toast.topupFailedSummary'),
|
summary: t('settings.billing.toast.topupFailedSummary'),
|
||||||
detail: e.message || t('settings.billing.toast.topupFailedDetail'),
|
detail: getApiErrorMessage(error, t('settings.billing.toast.topupFailedDetail')),
|
||||||
life: 5000,
|
life: 5000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
@@ -177,7 +505,10 @@ const handleTopup = async (amount: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadInvoice = (item: PaymentHistoryItem) => {
|
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||||
|
if (!item.id) return;
|
||||||
|
|
||||||
|
downloadingInvoiceId.value = item.id;
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'info',
|
severity: 'info',
|
||||||
summary: t('settings.billing.toast.downloadingSummary'),
|
summary: t('settings.billing.toast.downloadingSummary'),
|
||||||
@@ -185,14 +516,36 @@ const handleDownloadInvoice = (item: PaymentHistoryItem) => {
|
|||||||
life: 2000,
|
life: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
try {
|
||||||
|
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
|
||||||
|
const content = typeof response.data === 'string' ? response.data : '';
|
||||||
|
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = `${item.invoiceId}.txt`;
|
||||||
|
document.body.appendChild(anchor);
|
||||||
|
anchor.click();
|
||||||
|
document.body.removeChild(anchor);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.billing.toast.downloadedSummary'),
|
summary: t('settings.billing.toast.downloadedSummary'),
|
||||||
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
|
||||||
life: 3000,
|
life: 3000,
|
||||||
});
|
});
|
||||||
}, 1500);
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.billing.toast.downloadFailedSummary'),
|
||||||
|
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
downloadingInvoiceId.value = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTopupDialog = () => {
|
const openTopupDialog = () => {
|
||||||
@@ -214,6 +567,9 @@ const selectPreset = (amount: number) => {
|
|||||||
:title="t('settings.billing.walletBalance')"
|
:title="t('settings.billing.walletBalance')"
|
||||||
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
|
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
|
||||||
:button-label="t('settings.billing.topUp')"
|
:button-label="t('settings.billing.topUp')"
|
||||||
|
:subscription-title="subscriptionSummary.title"
|
||||||
|
:subscription-description="subscriptionSummary.description"
|
||||||
|
:subscription-tone="subscriptionSummary.tone"
|
||||||
@topup="openTopupDialog"
|
@topup="openTopupDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -223,23 +579,23 @@ const selectPreset = (amount: number) => {
|
|||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
:plans="plans"
|
:plans="plans"
|
||||||
:current-plan-id="currentPlanId"
|
:current-plan-id="currentPlanId"
|
||||||
:subscribing="subscribing"
|
:selecting-plan-id="selectedPlanId"
|
||||||
:format-money="formatMoney"
|
:format-money="formatMoney"
|
||||||
:get-plan-storage-text="getPlanStorageText"
|
:get-plan-storage-text="getPlanStorageText"
|
||||||
:get-plan-duration-text="getPlanDurationText"
|
:get-plan-duration-text="getPlanDurationText"
|
||||||
:get-plan-uploads-text="getPlanUploadsText"
|
:get-plan-uploads-text="getPlanUploadsText"
|
||||||
:current-plan-label="t('settings.billing.currentPlan')"
|
:current-plan-label="t('settings.billing.currentPlan')"
|
||||||
:processing-label="t('settings.billing.processing')"
|
:selecting-label="t('settings.billing.upgradeDialog.selecting')"
|
||||||
:upgrade-label="t('settings.billing.upgrade')"
|
:choose-label="t('settings.billing.upgradeDialog.choosePlan')"
|
||||||
@subscribe="subscribe"
|
@select="openUpgradeDialog"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BillingUsageSection
|
<BillingUsageSection
|
||||||
:storage-title="t('settings.billing.storage')"
|
:storage-title="t('settings.billing.storage')"
|
||||||
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
|
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
|
||||||
:storage-percentage="storagePercentage"
|
:storage-percentage="storagePercentage"
|
||||||
:uploads-title="t('settings.billing.monthlyUploads')"
|
:uploads-title="t('settings.billing.totalVideos')"
|
||||||
:uploads-description="t('settings.billing.uploadsUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
|
:uploads-description="t('settings.billing.totalVideosUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
|
||||||
:uploads-percentage="uploadsPercentage"
|
:uploads-percentage="uploadsPercentage"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -247,6 +603,8 @@ const selectPreset = (amount: number) => {
|
|||||||
:title="t('settings.billing.paymentHistory')"
|
:title="t('settings.billing.paymentHistory')"
|
||||||
:description="t('settings.billing.paymentHistorySubtitle')"
|
:description="t('settings.billing.paymentHistorySubtitle')"
|
||||||
:items="paymentHistory"
|
:items="paymentHistory"
|
||||||
|
:loading="historyLoading"
|
||||||
|
:downloading-id="downloadingInvoiceId"
|
||||||
:format-money="formatMoney"
|
:format-money="formatMoney"
|
||||||
:get-status-styles="getStatusStyles"
|
:get-status-styles="getStatusStyles"
|
||||||
:get-status-label="getStatusLabel"
|
:get-status-label="getStatusLabel"
|
||||||
@@ -261,6 +619,180 @@ const selectPreset = (amount: number) => {
|
|||||||
/>
|
/>
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
|
|
||||||
|
<AppDialog
|
||||||
|
:visible="upgradeDialogVisible"
|
||||||
|
:title="t('settings.billing.upgradeDialog.title')"
|
||||||
|
maxWidthClass="max-w-2xl"
|
||||||
|
@update:visible="onUpgradeDialogVisibilityChange"
|
||||||
|
@close="closeUpgradeDialog"
|
||||||
|
>
|
||||||
|
<div v-if="selectedPlan" class="space-y-5">
|
||||||
|
<div class="rounded-lg border border-border bg-muted/20 p-4">
|
||||||
|
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
|
||||||
|
{{ t('settings.billing.upgradeDialog.selectedPlan') }}
|
||||||
|
</p>
|
||||||
|
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
|
||||||
|
<p class="mt-1 text-sm text-foreground/70">
|
||||||
|
{{ selectedPlan.description || t('settings.billing.availablePlansHint') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-left md:text-right">
|
||||||
|
<p class="text-xs text-foreground/50">{{ t('settings.billing.upgradeDialog.basePrice') }}</p>
|
||||||
|
<p class="mt-1 text-2xl font-semibold text-foreground">{{ formatMoney(selectedPlan.price || 0) }}</p>
|
||||||
|
<p class="text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.perMonthBase') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.termTitle') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.termHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<button
|
||||||
|
v-for="months in TERM_OPTIONS"
|
||||||
|
:key="months"
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border px-4 py-3 text-left transition-all',
|
||||||
|
selectedTermMonths === months
|
||||||
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
|
: 'border-border bg-surface text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||||
|
]"
|
||||||
|
@click="selectedTermMonths = months"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium">{{ formatTermLabel(months) }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">{{ formatMoney((selectedPlan.price || 0) * months) }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-3">
|
||||||
|
<div class="rounded-lg border border-border bg-surface p-4">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.totalLabel') }}</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(selectedTotalAmount) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-lg border border-border bg-surface p-4">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.walletBalanceLabel') }}</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(walletBalance) }}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="rounded-lg border p-4"
|
||||||
|
:class="selectedNeedsTopup
|
||||||
|
? 'border-warning/30 bg-warning/10'
|
||||||
|
: 'border-success/20 bg-success/5'"
|
||||||
|
>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
|
||||||
|
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
|
||||||
|
{{ formatMoney(selectedShortfall) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedNeedsTopup" class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.paymentMethodHint') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-3 md:grid-cols-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border p-4 text-left transition-all',
|
||||||
|
selectedPaymentMethod === 'wallet'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
|
||||||
|
]"
|
||||||
|
@click="selectUpgradePaymentMethod('wallet')"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.wallet') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">
|
||||||
|
{{ t('settings.billing.upgradeDialog.walletOptionDescription') }}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
:class="[
|
||||||
|
'rounded-lg border p-4 text-left transition-all',
|
||||||
|
selectedPaymentMethod === 'topup'
|
||||||
|
? 'border-primary bg-primary/5'
|
||||||
|
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
|
||||||
|
]"
|
||||||
|
@click="selectUpgradePaymentMethod('topup')"
|
||||||
|
>
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.topup') }}</p>
|
||||||
|
<p class="mt-1 text-xs text-foreground/60">
|
||||||
|
{{ t('settings.billing.upgradeDialog.topupOptionDescription', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
|
||||||
|
{{ t('settings.billing.upgradeDialog.walletCoveredHint') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
|
||||||
|
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.topupAmountLabel') }}</label>
|
||||||
|
<AppInput
|
||||||
|
:model-value="purchaseTopupAmount"
|
||||||
|
type="number"
|
||||||
|
min="0.01"
|
||||||
|
step="0.01"
|
||||||
|
:placeholder="t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
|
||||||
|
@update:model-value="updatePurchaseTopupAmount"
|
||||||
|
/>
|
||||||
|
<p class="text-xs text-foreground/60">
|
||||||
|
{{ t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
|
||||||
|
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning"
|
||||||
|
>
|
||||||
|
{{ t('settings.billing.upgradeDialog.walletInsufficientHint', { shortfall: formatMoney(selectedShortfall) }) }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
|
||||||
|
{{ purchaseError }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<p class="text-xs text-foreground/60">
|
||||||
|
{{ t('settings.billing.upgradeDialog.footerHint') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex justify-end gap-3">
|
||||||
|
<AppButton
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
:disabled="purchaseLoading"
|
||||||
|
@click="closeUpgradeDialog"
|
||||||
|
>
|
||||||
|
{{ t('common.cancel') }}
|
||||||
|
</AppButton>
|
||||||
|
<AppButton
|
||||||
|
size="sm"
|
||||||
|
:loading="purchaseLoading"
|
||||||
|
:disabled="!canSubmitUpgrade"
|
||||||
|
@click="submitUpgrade"
|
||||||
|
>
|
||||||
|
{{ upgradeSubmitLabel }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</AppDialog>
|
||||||
|
|
||||||
<BillingTopupDialog
|
<BillingTopupDialog
|
||||||
:visible="topupDialogVisible"
|
:visible="topupDialogVisible"
|
||||||
:title="t('settings.billing.topupDialog.title')"
|
:title="t('settings.billing.topupDialog.title')"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { client } from '@/api/client';
|
||||||
import AppButton from '@/components/app/AppButton.vue';
|
import AppButton from '@/components/app/AppButton.vue';
|
||||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||||
@@ -8,25 +9,50 @@ import { useAppToast } from '@/composables/useAppToast';
|
|||||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
import { ref } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
|
const router = useRouter();
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const confirm = useAppConfirm();
|
const confirm = useAppConfirm();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const deletingAccount = ref(false);
|
||||||
|
const clearingData = ref(false);
|
||||||
|
|
||||||
const handleDeleteAccount = () => {
|
const handleDeleteAccount = () => {
|
||||||
confirm.require({
|
confirm.require({
|
||||||
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
|
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
|
||||||
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
|
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
|
||||||
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
|
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
|
||||||
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
|
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
|
||||||
accept: () => {
|
accept: async () => {
|
||||||
toast.add({
|
deletingAccount.value = true;
|
||||||
severity: 'info',
|
try {
|
||||||
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
|
await client.me.deleteMe({ baseUrl: '/r' });
|
||||||
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
|
|
||||||
life: 5000,
|
auth.$reset();
|
||||||
});
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
|
||||||
|
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
await router.push('/login');
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.dangerZone.toast.failedSummary'),
|
||||||
|
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
deletingAccount.value = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -37,13 +63,29 @@ const handleClearData = () => {
|
|||||||
header: t('settings.dangerZone.confirm.clearDataHeader'),
|
header: t('settings.dangerZone.confirm.clearDataHeader'),
|
||||||
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
|
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
|
||||||
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
|
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
|
||||||
accept: () => {
|
accept: async () => {
|
||||||
toast.add({
|
clearingData.value = true;
|
||||||
severity: 'info',
|
try {
|
||||||
summary: t('settings.dangerZone.toast.clearDataSummary'),
|
await client.me.clearDataCreate({ baseUrl: '/r' });
|
||||||
detail: t('settings.dangerZone.toast.clearDataDetail'),
|
|
||||||
life: 5000,
|
await auth.fetchMe();
|
||||||
});
|
toast.add({
|
||||||
|
severity: 'success',
|
||||||
|
summary: t('settings.dangerZone.toast.clearDataSummary'),
|
||||||
|
detail: t('settings.dangerZone.toast.clearDataDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.dangerZone.toast.failedSummary'),
|
||||||
|
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
clearingData.value = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -66,7 +108,7 @@ const handleClearData = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
|
<AppButton variant="danger" size="sm" :loading="deletingAccount" :disabled="clearingData" @click="handleDeleteAccount">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<TrashIcon class="w-4 h-4" />
|
<TrashIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -90,7 +132,7 @@ const handleClearData = () => {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<AppButton variant="danger" size="sm" @click="handleClearData">
|
<AppButton variant="danger" size="sm" :loading="clearingData" :disabled="deletingAccount" @click="handleClearData">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<SlidersIcon class="w-4 h-4" />
|
<SlidersIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { client } from '@/api/client';
|
||||||
import AppButton from '@/components/app/AppButton.vue';
|
import AppButton from '@/components/app/AppButton.vue';
|
||||||
import AppDialog from '@/components/app/AppDialog.vue';
|
import AppDialog from '@/components/app/AppDialog.vue';
|
||||||
import AppInput from '@/components/app/AppInput.vue';
|
import AppInput from '@/components/app/AppInput.vue';
|
||||||
@@ -10,23 +11,99 @@ import { useAppConfirm } from '@/composables/useAppConfirm';
|
|||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import { computed, ref } from 'vue';
|
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||||
|
import { useQuery } from '@pinia/colada';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const confirm = useAppConfirm();
|
const confirm = useAppConfirm();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const domains = ref([
|
type DomainApiItem = {
|
||||||
{ id: '1', name: 'example.com', addedAt: '2024-01-15' },
|
id?: string;
|
||||||
{ id: '2', name: 'mysite.org', addedAt: '2024-02-20' },
|
name?: string;
|
||||||
]);
|
created_at?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DomainItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
addedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
const newDomain = ref('');
|
const newDomain = ref('');
|
||||||
const showAddDialog = ref(false);
|
const showAddDialog = ref(false);
|
||||||
|
const adding = ref(false);
|
||||||
|
const removingId = ref<string | null>(null);
|
||||||
|
|
||||||
const handleAddDomain = () => {
|
const normalizeDomainInput = (value: string) => value
|
||||||
if (!newDomain.value.trim()) {
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/^https?:\/\//, '')
|
||||||
|
.replace(/^www\./, '')
|
||||||
|
.replace(/\/$/, '');
|
||||||
|
|
||||||
|
const formatDate = (value?: string) => {
|
||||||
|
if (!value) return '-';
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return value.split('T')[0] || value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.toISOString().split('T')[0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||||
|
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||||
|
name: item.name || '',
|
||||||
|
addedAt: formatDate(item.created_at),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: domainsSnapshot, error, isPending, refetch } = useQuery({
|
||||||
|
key: () => ['settings', 'domains'],
|
||||||
|
query: async () => {
|
||||||
|
const response = await client.domains.domainsList({ baseUrl: '/r' });
|
||||||
|
return ((((response.data as any)?.data?.domains) || []) as DomainApiItem[]).map(mapDomainItem);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const domains = computed(() => domainsSnapshot.value || []);
|
||||||
|
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
|
||||||
|
|
||||||
|
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
||||||
|
|
||||||
|
const refetchDomains = () => refetch((fetchError) => {
|
||||||
|
throw fetchError;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(error, (value, previous) => {
|
||||||
|
if (!value || value === previous || adding.value || removingId.value !== null) return;
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||||
|
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const openAddDialog = () => {
|
||||||
|
newDomain.value = '';
|
||||||
|
showAddDialog.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeAddDialog = () => {
|
||||||
|
showAddDialog.value = false;
|
||||||
|
newDomain.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddDomain = async () => {
|
||||||
|
if (adding.value) return;
|
||||||
|
|
||||||
|
const domainName = normalizeDomainInput(newDomain.value);
|
||||||
|
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||||
@@ -36,7 +113,7 @@ const handleAddDomain = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const exists = domains.value.some(d => d.name === newDomain.value.trim().toLowerCase());
|
const exists = domains.value.some(domain => domain.name === domainName);
|
||||||
if (exists) {
|
if (exists) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
@@ -47,48 +124,95 @@ const handleAddDomain = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const domainName = newDomain.value.trim().toLowerCase();
|
adding.value = true;
|
||||||
domains.value.push({
|
try {
|
||||||
id: Math.random().toString(36).substring(2, 9),
|
await client.domains.domainsCreate({
|
||||||
name: domainName,
|
name: domainName,
|
||||||
addedAt: new Date().toISOString().split('T')[0],
|
}, { baseUrl: '/r' });
|
||||||
});
|
|
||||||
|
|
||||||
newDomain.value = '';
|
await refetchDomains();
|
||||||
showAddDialog.value = false;
|
closeAddDialog();
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||||
life: 3000,
|
life: 3000,
|
||||||
});
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
const message = String(e?.message || '').toLowerCase();
|
||||||
|
|
||||||
|
if (message.includes('already exists')) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||||
|
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} else if (message.includes('invalid domain')) {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||||
|
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||||
|
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
adding.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveDomain = (domain: typeof domains.value[0]) => {
|
const handleRemoveDomain = (domain: DomainItem) => {
|
||||||
confirm.require({
|
confirm.require({
|
||||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||||
accept: () => {
|
accept: async () => {
|
||||||
const index = domains.value.findIndex(d => d.id === domain.id);
|
removingId.value = domain.id;
|
||||||
if (index !== -1) {
|
try {
|
||||||
domains.value.splice(index, 1);
|
await client.domains.domainsDelete(domain.id, { baseUrl: '/r' });
|
||||||
|
await refetchDomains();
|
||||||
|
toast.add({
|
||||||
|
severity: 'info',
|
||||||
|
summary: t('settings.domainsDns.toast.removedSummary'),
|
||||||
|
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.domainsDns.toast.failedSummary'),
|
||||||
|
detail: e.message || t('settings.domainsDns.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
removingId.value = null;
|
||||||
}
|
}
|
||||||
toast.add({
|
|
||||||
severity: 'info',
|
|
||||||
summary: t('settings.domainsDns.toast.removedSummary'),
|
|
||||||
detail: t('settings.domainsDns.toast.removedDetail', { domain: domain.name }),
|
|
||||||
life: 3000,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
|
const copyIframeCode = async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(iframeCode.value);
|
||||||
|
} catch {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = iframeCode.value;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
|
|
||||||
const copyIframeCode = () => {
|
|
||||||
navigator.clipboard.writeText(iframeCode.value);
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||||
@@ -105,7 +229,7 @@ const copyIframeCode = () => {
|
|||||||
bodyClass=""
|
bodyClass=""
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<AppButton size="sm" @click="showAddDialog = true">
|
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<PlusIcon class="w-4 h-4" />
|
<PlusIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -117,7 +241,9 @@ const copyIframeCode = () => {
|
|||||||
{{ t('settings.domainsDns.infoBanner') }}
|
{{ t('settings.domainsDns.infoBanner') }}
|
||||||
</SettingsNotice>
|
</SettingsNotice>
|
||||||
|
|
||||||
<div class="border-b border-border mt-4">
|
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||||
|
|
||||||
|
<div v-else class="border-b border-border mt-4">
|
||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-muted/30">
|
<thead class="bg-muted/30">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -127,27 +253,34 @@ const copyIframeCode = () => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="divide-y divide-border">
|
<tbody class="divide-y divide-border">
|
||||||
<tr
|
<template v-if="domains.length > 0">
|
||||||
v-for="domain in domains"
|
<tr
|
||||||
:key="domain.id"
|
v-for="domain in domains"
|
||||||
class="hover:bg-muted/30 transition-all"
|
:key="domain.id"
|
||||||
>
|
class="hover:bg-muted/30 transition-all"
|
||||||
<td class="px-6 py-3">
|
>
|
||||||
<div class="flex items-center gap-2">
|
<td class="px-6 py-3">
|
||||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||||
</div>
|
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||||
</td>
|
</div>
|
||||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
</td>
|
||||||
<td class="px-6 py-3 text-right">
|
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||||
<AppButton variant="ghost" size="sm" @click="handleRemoveDomain(domain)">
|
<td class="px-6 py-3 text-right">
|
||||||
<template #icon>
|
<AppButton
|
||||||
<TrashIcon class="w-4 h-4 text-danger" />
|
variant="ghost"
|
||||||
</template>
|
size="sm"
|
||||||
</AppButton>
|
:disabled="adding || removingId !== null"
|
||||||
</td>
|
@click="handleRemoveDomain(domain)"
|
||||||
</tr>
|
>
|
||||||
<tr v-if="domains.length === 0">
|
<template #icon>
|
||||||
|
<TrashIcon class="w-4 h-4 text-danger" />
|
||||||
|
</template>
|
||||||
|
</AppButton>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
<tr v-else>
|
||||||
<td colspan="3" class="px-6 py-12 text-center">
|
<td colspan="3" class="px-6 py-12 text-center">
|
||||||
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
|
||||||
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
<p class="text-sm text-foreground/60 mb-1">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||||
@@ -176,9 +309,10 @@ const copyIframeCode = () => {
|
|||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
:visible="showAddDialog"
|
:visible="showAddDialog"
|
||||||
@update:visible="showAddDialog = $event"
|
|
||||||
:title="t('settings.domainsDns.dialog.title')"
|
:title="t('settings.domainsDns.dialog.title')"
|
||||||
maxWidthClass="max-w-md"
|
maxWidthClass="max-w-md"
|
||||||
|
@update:visible="showAddDialog = $event"
|
||||||
|
@close="closeAddDialog"
|
||||||
>
|
>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@@ -202,15 +336,17 @@ const copyIframeCode = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
|
<div class="flex justify-end gap-2">
|
||||||
|
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||||
{{ t('common.cancel') }}
|
{{ t('common.cancel') }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
<AppButton size="sm" @click="handleAddDomain">
|
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<CheckIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
{{ t('settings.domainsDns.addDomain') }}
|
{{ t('settings.domainsDns.addDomain') }}
|
||||||
</AppButton>
|
</AppButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</AppDialog>
|
</AppDialog>
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { client } from '@/api/client';
|
||||||
import AppButton from '@/components/app/AppButton.vue';
|
import AppButton from '@/components/app/AppButton.vue';
|
||||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||||
@@ -6,22 +7,24 @@ import CheckIcon from '@/components/icons/CheckIcon.vue';
|
|||||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||||
import SendIcon from '@/components/icons/SendIcon.vue';
|
import SendIcon from '@/components/icons/SendIcon.vue';
|
||||||
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
|
||||||
|
import {
|
||||||
|
createNotificationSettingsDraft,
|
||||||
|
toNotificationPreferencesPayload,
|
||||||
|
useSettingsPreferencesQuery,
|
||||||
|
} from '@/composables/useSettingsPreferencesQuery';
|
||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||||
|
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const notificationSettings = ref({
|
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||||
email: true,
|
|
||||||
push: true,
|
|
||||||
marketing: false,
|
|
||||||
telegram: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const notificationSettings = ref(createNotificationSettingsDraft());
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
const notificationTypes = computed(() => [
|
const notificationTypes = computed(() => [
|
||||||
@@ -59,10 +62,40 @@ const notificationTypes = computed(() => [
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||||
|
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||||
|
|
||||||
|
const refetchPreferences = () => refetch((fetchError) => {
|
||||||
|
throw fetchError;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(preferencesSnapshot, (snapshot) => {
|
||||||
|
if (!snapshot) return;
|
||||||
|
notificationSettings.value = createNotificationSettingsDraft(snapshot);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(error, (value, previous) => {
|
||||||
|
if (!value || value === previous || saving.value) return;
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.notificationSettings.toast.failedSummary'),
|
||||||
|
detail: (value as any)?.message || t('settings.notificationSettings.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (saving.value || !preferencesSnapshot.value) return;
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await client.settings.preferencesUpdate(
|
||||||
|
toNotificationPreferencesPayload(notificationSettings.value),
|
||||||
|
{ baseUrl: '/r' },
|
||||||
|
);
|
||||||
|
await refetchPreferences();
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.notificationSettings.toast.savedSummary'),
|
summary: t('settings.notificationSettings.toast.savedSummary'),
|
||||||
@@ -88,7 +121,7 @@ const handleSave = async () => {
|
|||||||
:description="t('settings.content.notifications.subtitle')"
|
:description="t('settings.content.notifications.subtitle')"
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<AppButton size="sm" :loading="saving" @click="handleSave">
|
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<CheckIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -96,20 +129,29 @@ const handleSave = async () => {
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SettingsRow
|
<template v-if="isInitialLoading">
|
||||||
v-for="type in notificationTypes"
|
<SettingsRowSkeleton
|
||||||
:key="type.key"
|
v-for="type in notificationTypes"
|
||||||
:title="type.title"
|
:key="type.key"
|
||||||
:description="type.description"
|
/>
|
||||||
:iconBoxClass="type.bgColor"
|
</template>
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
<template v-else>
|
||||||
<AppSwitch v-model="notificationSettings[type.key]" />
|
<SettingsRow
|
||||||
</template>
|
v-for="type in notificationTypes"
|
||||||
</SettingsRow>
|
:key="type.key"
|
||||||
|
:title="type.title"
|
||||||
|
:description="type.description"
|
||||||
|
:iconBoxClass="type.bgColor"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<AppSwitch v-model="notificationSettings[type.key]" :disabled="isInteractionDisabled" />
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</template>
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,94 +1,128 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue';
|
import { client } from '@/api/client';
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import AppButton from '@/components/app/AppButton.vue';
|
import AppButton from '@/components/app/AppButton.vue';
|
||||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||||
|
import {
|
||||||
|
createPlayerSettingsDraft,
|
||||||
|
toPlayerPreferencesPayload,
|
||||||
|
useSettingsPreferencesQuery,
|
||||||
|
} from '@/composables/useSettingsPreferencesQuery';
|
||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||||
|
import SettingsRowSkeleton from '@/routes/settings/components/SettingsRowSkeleton.vue';
|
||||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const playerSettings = ref({
|
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||||
autoplay: true,
|
|
||||||
loop: false,
|
|
||||||
muted: false,
|
|
||||||
showControls: true,
|
|
||||||
pip: true,
|
|
||||||
airplay: true,
|
|
||||||
Chromecast: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const playerSettings = ref(createPlayerSettingsDraft());
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
|
|
||||||
|
const settingsItems = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'autoplay' as const,
|
||||||
|
title: 'settings.playerSettings.items.autoplay.title',
|
||||||
|
description: '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: 'settings.playerSettings.items.loop.title',
|
||||||
|
description: 'settings.playerSettings.items.loop.description',
|
||||||
|
svg: `<svg 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: 'settings.playerSettings.items.muted.title',
|
||||||
|
description: 'settings.playerSettings.items.muted.description',
|
||||||
|
svg: `<svg xmlns="http://www.w3.org/2000/svg" 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: 'settings.playerSettings.items.showControls.title',
|
||||||
|
description: '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: 'settings.playerSettings.items.pip.title',
|
||||||
|
description: 'settings.playerSettings.items.pip.description',
|
||||||
|
svg: `<svg 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: 'settings.playerSettings.items.airplay.title',
|
||||||
|
description: 'settings.playerSettings.items.airplay.description',
|
||||||
|
svg: `<svg 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: 'settings.playerSettings.items.chromecast.title',
|
||||||
|
description: 'settings.playerSettings.items.chromecast.description',
|
||||||
|
svg: `<svg 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>`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'encrytion_m3u8' as const,
|
||||||
|
title: 'settings.playerSettings.items.encrytion_m3u8.title',
|
||||||
|
description: 'settings.playerSettings.items.encrytion_m3u8.description',
|
||||||
|
svg: `<svg xmlns="http://www.w3.org/2000/svg" class="fill-primary/30" viewBox="0 0 564 564"><path d="M26 74c0-26 22-48 48-48h134c3 0 7 0 10 1v103c0 31 25 56 56 56h120v11c-38 18-64 56-64 101v29c-29 16-48 47-48 83v96H74c-26 0-48-21-48-48V74z"/><path d="M208 26H74c-26 0-48 22-48 48v384c0 27 22 48 48 48h208c0 6 1 11 1 16H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h134c17 0 33 7 45 19l122 122c10 10 16 22 18 35H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1zm156 137L241 40c-2-2-4-4-7-6v96c0 22 18 40 40 40h96c-2-3-4-5-6-7zm126 135c0-26-21-48-48-48-26 0-48 22-48 48v64h96v-64zM346 410v96c0 18 14 32 32 32h128c18 0 32-14 32-32v-96c0-18-14-32-32-32H378c-18 0-32 14-32 32zm160-112v64c27 0 48 22 48 48v96c0 27-21 48-48 48H378c-26 0-48-21-48-48v-96c0-26 22-48 48-48v-64c0-35 29-64 64-64s64 29 64 64z" class="fill-primary"/></svg>`,
|
||||||
|
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
|
||||||
|
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
|
||||||
|
|
||||||
|
|
||||||
|
watch(preferencesSnapshot, (snapshot) => {
|
||||||
|
if (!snapshot) return;
|
||||||
|
playerSettings.value = createPlayerSettingsDraft(snapshot);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
watch(error, (value, previous) => {
|
||||||
|
if (!value || value === previous || saving.value) return;
|
||||||
|
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||||
|
detail: (value as any)?.message || t('settings.playerSettings.toast.failedDetail'),
|
||||||
|
life: 5000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
|
if (saving.value || !preferencesSnapshot.value) return;
|
||||||
|
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
await client.settings.preferencesUpdate(
|
||||||
|
toPlayerPreferencesPayload(playerSettings.value),
|
||||||
|
{ baseUrl: '/r' },
|
||||||
|
);
|
||||||
|
await refetch();
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'success',
|
severity: 'success',
|
||||||
summary: t('settings.playerSettings.toast.savedSummary'),
|
summary: t('settings.playerSettings.toast.savedSummary'),
|
||||||
detail: t('settings.playerSettings.toast.savedDetail'),
|
detail: t('settings.playerSettings.toast.savedDetail'),
|
||||||
life: 3000
|
life: 3000,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
toast.add({
|
toast.add({
|
||||||
severity: 'error',
|
severity: 'error',
|
||||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||||
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
|
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
|
||||||
life: 5000
|
life: 5000,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false;
|
saving.value = false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsItems = computed(() => [
|
|
||||||
{
|
|
||||||
key: 'autoplay' as const,
|
|
||||||
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: 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: 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: 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: 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: 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: 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -97,7 +131,7 @@ const settingsItems = computed(() => [
|
|||||||
:description="t('settings.content.player.subtitle')"
|
:description="t('settings.content.player.subtitle')"
|
||||||
>
|
>
|
||||||
<template #header-actions>
|
<template #header-actions>
|
||||||
<AppButton size="sm" :loading="saving" @click="handleSave">
|
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||||
<template #icon>
|
<template #icon>
|
||||||
<CheckIcon class="w-4 h-4" />
|
<CheckIcon class="w-4 h-4" />
|
||||||
</template>
|
</template>
|
||||||
@@ -105,20 +139,29 @@ const settingsItems = computed(() => [
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<SettingsRow
|
<template v-if="isInitialLoading">
|
||||||
v-for="item in settingsItems"
|
<SettingsRowSkeleton
|
||||||
:key="item.key"
|
v-for="item in settingsItems"
|
||||||
:title="item.title"
|
:key="item.key"
|
||||||
:description="item.description"
|
/>
|
||||||
iconBoxClass="bg-primary/10 text-primary"
|
</template>
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<span v-html="item.svg" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
<template v-else>
|
||||||
<AppSwitch v-model="playerSettings[item.key]" />
|
<SettingsRow
|
||||||
</template>
|
v-for="item in settingsItems"
|
||||||
</SettingsRow>
|
:key="item.key"
|
||||||
|
:title="$t(item.title)"
|
||||||
|
:description="$t(item.description)"
|
||||||
|
iconBoxClass="bg-primary/10 text-primary"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<span v-html="item.svg" class="h-6 w-6" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<AppSwitch v-model="playerSettings[item.key]" :disabled="isInteractionDisabled" />
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
|
</template>
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -19,10 +19,10 @@ import { computed, ref } from 'vue';
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
const confirm = useAppConfirm();
|
const confirm = useAppConfirm();
|
||||||
const { t } = useTranslation();
|
const { t, i18next } = useTranslation();
|
||||||
|
|
||||||
const languageSaving = ref(false);
|
const languageSaving = ref(false);
|
||||||
const selectedLanguage = ref('en');
|
const selectedLanguage = ref<string>(auth.user?.language || "en");
|
||||||
const languageOptions = computed(() => supportedLocales.map((value) => ({
|
const languageOptions = computed(() => supportedLocales.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: t(`settings.securityConnected.language.options.${value}`)
|
label: t(`settings.securityConnected.language.options.${value}`)
|
||||||
@@ -282,7 +282,7 @@ const disconnectTelegram = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
:title="t('settings.securityConnected.twoFactor.label')"
|
:title="t('settings.securityConnected.twoFactor.label')"
|
||||||
:description="twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled')"
|
:description="twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled')"
|
||||||
iconBoxClass="bg-primary/10"
|
iconBoxClass="bg-primary/10"
|
||||||
@@ -314,26 +314,6 @@ const disconnectTelegram = async () => {
|
|||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
<SettingsRow
|
|
||||||
:title="t('settings.securityConnected.logout.label')"
|
|
||||||
:description="t('settings.securityConnected.logout.detail')"
|
|
||||||
iconBoxClass="bg-danger/10"
|
|
||||||
hoverClass="hover:bg-danger/5"
|
|
||||||
>
|
|
||||||
<template #icon>
|
|
||||||
<XCircleIcon class="w-5 h-5 text-danger" />
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template #actions>
|
|
||||||
<AppButton variant="danger" size="sm" @click="handleLogout">
|
|
||||||
<template #icon>
|
|
||||||
<XCircleIcon class="w-4 h-4" />
|
|
||||||
</template>
|
|
||||||
{{ t('settings.securityConnected.logout.button') }}
|
|
||||||
</AppButton>
|
|
||||||
</template>
|
|
||||||
</SettingsRow>
|
|
||||||
|
|
||||||
<SettingsRow
|
<SettingsRow
|
||||||
:title="t('settings.securityConnected.email.label')"
|
:title="t('settings.securityConnected.email.label')"
|
||||||
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
|
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
|
||||||
@@ -380,6 +360,26 @@ const disconnectTelegram = async () => {
|
|||||||
</AppButton>
|
</AppButton>
|
||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
|
|
||||||
|
<SettingsRow
|
||||||
|
:title="t('settings.securityConnected.logout.label')"
|
||||||
|
:description="t('settings.securityConnected.logout.detail')"
|
||||||
|
iconBoxClass="bg-danger/10"
|
||||||
|
hoverClass="hover:bg-danger/5"
|
||||||
|
>
|
||||||
|
<template #icon>
|
||||||
|
<XCircleIcon class="w-5 h-5 text-danger" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #actions>
|
||||||
|
<AppButton variant="danger" size="sm" @click="handleLogout">
|
||||||
|
<template #icon>
|
||||||
|
<XCircleIcon class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ t('settings.securityConnected.logout.button') }}
|
||||||
|
</AppButton>
|
||||||
|
</template>
|
||||||
|
</SettingsRow>
|
||||||
</SettingsSectionCard>
|
</SettingsSectionCard>
|
||||||
|
|
||||||
<AppDialog
|
<AppDialog
|
||||||
|
|||||||
28
src/routes/settings/components/SettingsRowSkeleton.vue
Normal file
28
src/routes/settings/components/SettingsRowSkeleton.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
actionClass?: string;
|
||||||
|
titleClass?: string;
|
||||||
|
descriptionClass?: string;
|
||||||
|
}>(), {
|
||||||
|
actionClass: 'h-6 w-11',
|
||||||
|
titleClass: 'w-32',
|
||||||
|
descriptionClass: 'w-56 max-w-full',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between gap-4 px-6 py-4 animate-pulse">
|
||||||
|
<div class="flex min-w-0 items-center gap-4">
|
||||||
|
<div class="h-10 w-10 rounded-md bg-muted/50 shrink-0" />
|
||||||
|
|
||||||
|
<div class="min-w-0 space-y-2">
|
||||||
|
<div :class="cn('h-4 rounded bg-muted/50', titleClass)" />
|
||||||
|
<div :class="cn('h-3 rounded bg-muted/40', descriptionClass)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div :class="cn('shrink-0 rounded-full bg-muted/50', actionClass)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
40
src/routes/settings/components/SettingsTableSkeleton.vue
Normal file
40
src/routes/settings/components/SettingsTableSkeleton.vue
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
columns?: number;
|
||||||
|
rows?: number;
|
||||||
|
}>(), {
|
||||||
|
columns: 3,
|
||||||
|
rows: 4,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="border-b border-border mt-4">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-muted/30">
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
v-for="column in columns"
|
||||||
|
:key="column"
|
||||||
|
class="px-6 py-3"
|
||||||
|
>
|
||||||
|
<div class="h-3 w-20 rounded bg-muted/50 animate-pulse" />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-border">
|
||||||
|
<tr v-for="row in rows" :key="row" class="animate-pulse">
|
||||||
|
<td v-for="column in columns" :key="column" class="px-6 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="h-4 rounded bg-muted/50" :class="column === columns ? 'ml-auto w-16' : 'w-full max-w-[12rem]'" />
|
||||||
|
<div
|
||||||
|
v-if="column === 1"
|
||||||
|
class="h-3 w-24 rounded bg-muted/40"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -8,12 +8,17 @@ type PaymentHistoryItem = {
|
|||||||
plan: string;
|
plan: string;
|
||||||
status: string;
|
status: string;
|
||||||
invoiceId: string;
|
invoiceId: string;
|
||||||
|
currency: string;
|
||||||
|
kind: string;
|
||||||
|
details?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
items: PaymentHistoryItem[];
|
items: PaymentHistoryItem[];
|
||||||
|
loading?: boolean;
|
||||||
|
downloadingId?: string | null;
|
||||||
formatMoney: (amount: number) => string;
|
formatMoney: (amount: number) => string;
|
||||||
getStatusStyles: (status: string) => string;
|
getStatusStyles: (status: string) => string;
|
||||||
getStatusLabel: (status: string) => string;
|
getStatusLabel: (status: string) => string;
|
||||||
@@ -52,42 +57,58 @@ const emit = defineEmits<{
|
|||||||
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
|
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="items.length === 0" class="text-center py-12 text-foreground/60">
|
<div v-if="loading" class="px-4 py-6 space-y-3">
|
||||||
|
<div v-for="index in 3" :key="index" class="grid grid-cols-12 gap-4 items-center animate-pulse">
|
||||||
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-3 h-4 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-6 rounded bg-muted/50" />
|
||||||
|
<div class="col-span-2 h-8 rounded bg-muted/50" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="items.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">
|
<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" />
|
<DownloadIcon class="w-8 h-8 text-foreground/40" />
|
||||||
</div>
|
</div>
|
||||||
<p>{{ emptyLabel }}</p>
|
<p>{{ emptyLabel }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<template v-else>
|
||||||
v-for="item in items"
|
<div
|
||||||
:key="item.id"
|
v-for="item in items"
|
||||||
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
:key="item.id"
|
||||||
>
|
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
|
||||||
<div class="col-span-3">
|
>
|
||||||
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
<div class="col-span-3">
|
||||||
|
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<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>
|
||||||
|
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
|
||||||
|
{{ item.details.join(' · ') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2">
|
||||||
|
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
||||||
|
{{ getStatusLabel(item.status) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 flex justify-end">
|
||||||
|
<button
|
||||||
|
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
|
||||||
|
:disabled="downloadingId === item.id"
|
||||||
|
@click="emit('download', item)"
|
||||||
|
>
|
||||||
|
<DownloadIcon class="w-4 h-4" />
|
||||||
|
<span>{{ downloadingId === item.id ? '...' : downloadLabel }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-span-2">
|
</template>
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2">
|
|
||||||
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
|
|
||||||
{{ getStatusLabel(item.status) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-span-2 flex justify-end">
|
|
||||||
<button
|
|
||||||
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
|
|
||||||
@click="emit('download', item)"
|
|
||||||
>
|
|
||||||
<DownloadIcon class="w-4 h-4" />
|
|
||||||
<span>{{ downloadLabel }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,18 +9,18 @@ defineProps<{
|
|||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
plans: ModelPlan[];
|
plans: ModelPlan[];
|
||||||
currentPlanId?: string;
|
currentPlanId?: string;
|
||||||
subscribing: string | null;
|
selectingPlanId?: string | null;
|
||||||
formatMoney: (amount: number) => string;
|
formatMoney: (amount: number) => string;
|
||||||
getPlanStorageText: (plan: ModelPlan) => string;
|
getPlanStorageText: (plan: ModelPlan) => string;
|
||||||
getPlanDurationText: (plan: ModelPlan) => string;
|
getPlanDurationText: (plan: ModelPlan) => string;
|
||||||
getPlanUploadsText: (plan: ModelPlan) => string;
|
getPlanUploadsText: (plan: ModelPlan) => string;
|
||||||
currentPlanLabel: string;
|
currentPlanLabel: string;
|
||||||
processingLabel: string;
|
selectingLabel: string;
|
||||||
upgradeLabel: string;
|
chooseLabel: string;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'subscribe', plan: ModelPlan): void;
|
(e: 'select', plan: ModelPlan): void;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -44,22 +44,32 @@ const emit = defineEmits<{
|
|||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div
|
<div
|
||||||
v-for="plan in plans"
|
v-for="plan in plans.sort((a,b) => a.price - b.price)"
|
||||||
:key="plan.id"
|
:key="plan.id"
|
||||||
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
|
:class="[
|
||||||
|
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||||
|
plan.id === currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
|
||||||
|
]"
|
||||||
>
|
>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
|
||||||
|
<span
|
||||||
|
v-if="plan.id === currentPlanId"
|
||||||
|
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
|
||||||
|
>
|
||||||
|
{{ currentPlanLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
|
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
|
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
|
||||||
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
|
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="space-y-2 mb-4 text-sm">
|
<ul class="space-y-2 mb-4 text-sm">
|
||||||
<li class="flex items-center gap-2 text-foreground/70">
|
<!-- <li class="flex items-center gap-2 text-foreground/70">
|
||||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||||
{{ getPlanStorageText(plan) }}
|
{{ getPlanStorageText(plan) }}
|
||||||
</li>
|
</li>
|
||||||
@@ -70,24 +80,29 @@ const emit = defineEmits<{
|
|||||||
<li class="flex items-center gap-2 text-foreground/70">
|
<li class="flex items-center gap-2 text-foreground/70">
|
||||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||||
{{ getPlanUploadsText(plan) }}
|
{{ getPlanUploadsText(plan) }}
|
||||||
|
</li> -->
|
||||||
|
<li
|
||||||
|
v-for="feature in plan.features || []"
|
||||||
|
:key="feature"
|
||||||
|
class="flex items-center gap-2 text-foreground/70"
|
||||||
|
>
|
||||||
|
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||||
|
{{ feature }}
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
:disabled="!!subscribing || plan.id === currentPlanId"
|
v-if="plan.id !== currentPlanId"
|
||||||
|
:disabled="selectingPlanId === plan.id"
|
||||||
:class="[
|
:class="[
|
||||||
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
|
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-a',
|
||||||
plan.id === currentPlanId
|
selectingPlanId === plan.id
|
||||||
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
|
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
||||||
: subscribing === plan.id
|
: 'bg-primary text-white hover:bg-primary/90'
|
||||||
? 'bg-muted/50 text-foreground/60 cursor-wait'
|
|
||||||
: 'bg-primary text-primary-foreground hover:bg-primary/90'
|
|
||||||
]"
|
]"
|
||||||
@click="emit('subscribe', plan)"
|
@click="emit('select', plan)"
|
||||||
>
|
>
|
||||||
{{ plan.id === currentPlanId
|
{{ selectingPlanId === plan.id ? selectingLabel : chooseLabel }}
|
||||||
? currentPlanLabel
|
|
||||||
: (subscribing === plan.id ? processingLabel : upgradeLabel) }}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,9 @@ defineProps<{
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
buttonLabel: string;
|
buttonLabel: string;
|
||||||
|
subscriptionTitle?: string;
|
||||||
|
subscriptionDescription?: string;
|
||||||
|
subscriptionTone?: 'default' | 'warning';
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -22,12 +25,25 @@ const emit = defineEmits<{
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<AppButton size="sm" @click="emit('topup')">
|
<div class="flex flex-col items-end gap-2">
|
||||||
<template #icon>
|
<!-- <div
|
||||||
<PlusIcon class="w-4 h-4" />
|
v-if="subscriptionTitle || subscriptionDescription"
|
||||||
</template>
|
class="rounded-md border px-3 py-2 text-right"
|
||||||
{{ buttonLabel }}
|
:class="subscriptionTone === 'warning'
|
||||||
</AppButton>
|
? 'border-warning/30 bg-warning/10 text-warning'
|
||||||
|
: 'border-border bg-muted/30 text-foreground/70'"
|
||||||
|
>
|
||||||
|
<p v-if="subscriptionTitle" class="text-xs font-medium">{{ subscriptionTitle }}</p>
|
||||||
|
<p v-if="subscriptionDescription" class="mt-0.5 text-xs">{{ subscriptionDescription }}</p>
|
||||||
|
</div> -->
|
||||||
|
|
||||||
|
<AppButton size="sm" @click="emit('topup')">
|
||||||
|
<template #icon>
|
||||||
|
<PlusIcon class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
{{ buttonLabel }}
|
||||||
|
</AppButton>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</SettingsRow>
|
</SettingsRow>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelVideo } from '@/api/client';
|
import { client, type ModelVideo } from '@/api/client';
|
||||||
import { fetchMockVideoById } from '@/mocks/videos';
|
|
||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import { computed, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
@@ -22,7 +21,8 @@ const { t } = useTranslation();
|
|||||||
const fetchVideo = async () => {
|
const fetchVideo = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const videoData = await fetchMockVideoById(props.videoId);
|
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||||
|
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
|
||||||
if (videoData) {
|
if (videoData) {
|
||||||
video.value = videoData;
|
video.value = videoData;
|
||||||
}
|
}
|
||||||
@@ -44,11 +44,13 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
|
|||||||
const shareLinks = computed(() => {
|
const shareLinks = computed(() => {
|
||||||
if (!video.value) return [];
|
if (!video.value) return [];
|
||||||
const v = video.value;
|
const v = video.value;
|
||||||
|
const playbackPath = v.url || `/play/index/${v.id}`;
|
||||||
|
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
key: 'embed',
|
key: 'embed',
|
||||||
label: t('video.copyModal.embedPlayer'),
|
label: t('video.copyModal.embedPlayer'),
|
||||||
value: `${baseUrl.value}/play/index/${v.id}`,
|
value: playbackUrl,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'thumbnail',
|
key: 'thumbnail',
|
||||||
@@ -58,7 +60,7 @@ const shareLinks = computed(() => {
|
|||||||
{
|
{
|
||||||
key: 'hls',
|
key: 'hls',
|
||||||
label: t('video.copyModal.hls'),
|
label: t('video.copyModal.hls'),
|
||||||
value: v.hls_path ? `${baseUrl.value}/hls/getlink/${v.id}/${v.hls_token}/${v.hls_path}` : '',
|
value: playbackUrl,
|
||||||
placeholder: t('video.copyModal.hlsPlaceholder'),
|
placeholder: t('video.copyModal.hlsPlaceholder'),
|
||||||
hint: t('video.copyModal.hlsHint'),
|
hint: t('video.copyModal.hlsHint'),
|
||||||
},
|
},
|
||||||
@@ -129,7 +131,7 @@ watch(() => props.videoId, (newId) => {
|
|||||||
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
|
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
|
<AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
|
||||||
input-class="!font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" />
|
input-class="!font-mono !text-xs" wrapperClass="w-full" @click="($event.target as HTMLInputElement)?.select()" />
|
||||||
<AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
|
<AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
|
||||||
@click="copyToClipboard(link.value, link.key)" class="shrink-0">
|
@click="copyToClipboard(link.value, link.key)" class="shrink-0">
|
||||||
<!-- Copy icon -->
|
<!-- Copy icon -->
|
||||||
|
|||||||
@@ -1,233 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
|
||||||
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 { computed, onMounted, ref } from 'vue';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
|
||||||
import VideoEditForm from './components/Detail/VideoEditForm.vue';
|
|
||||||
import VideoHeader from './components/Detail/VideoInfoHeader.vue';
|
|
||||||
import VideoPlayer from './components/Detail/VideoPlayer.vue';
|
|
||||||
import VideoSkeleton from './components/Detail/VideoSkeleton.vue';
|
|
||||||
|
|
||||||
const route = useRoute();
|
|
||||||
const router = useRouter();
|
|
||||||
const toast = useAppToast();
|
|
||||||
const confirm = useAppConfirm();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const videoId = route.params.id as string;
|
|
||||||
const video = ref<ModelVideo | null>(null);
|
|
||||||
const loading = ref(true);
|
|
||||||
const saving = ref(false);
|
|
||||||
const isEditing = ref(false);
|
|
||||||
|
|
||||||
const form = ref({
|
|
||||||
title: '',
|
|
||||||
description: '',
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchVideo = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const videoData = await fetchMockVideoById(videoId);
|
|
||||||
if (videoData) {
|
|
||||||
video.value = videoData;
|
|
||||||
form.value.title = videoData.title || '';
|
|
||||||
form.value.description = videoData.description || '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch video:', error);
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleReload = async () => {
|
|
||||||
toast.add({
|
|
||||||
severity: 'info',
|
|
||||||
summary: t('video.detailPage.toast.reloadSummary'),
|
|
||||||
detail: t('video.detailPage.toast.reloadDetail'),
|
|
||||||
life: 2000
|
|
||||||
});
|
|
||||||
await fetchVideo();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleEdit = () => {
|
|
||||||
isEditing.value = !isEditing.value;
|
|
||||||
if (!isEditing.value && video.value) {
|
|
||||||
form.value.title = video.value.title || '';
|
|
||||||
form.value.description = video.value.description || '';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
saving.value = true;
|
|
||||||
try {
|
|
||||||
await updateMockVideo(videoId, form.value);
|
|
||||||
|
|
||||||
if (video.value) {
|
|
||||||
video.value.title = form.value.title;
|
|
||||||
video.value.description = form.value.description;
|
|
||||||
}
|
|
||||||
|
|
||||||
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: t('video.detailModal.toast.saveErrorSummary'),
|
|
||||||
detail: t('video.detailModal.toast.saveErrorDetail'),
|
|
||||||
life: 3000
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
saving.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = () => {
|
|
||||||
confirm.require({
|
|
||||||
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: 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: t('video.detailPage.toast.deleteErrorSummary'),
|
|
||||||
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
|
||||||
life: 3000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
reject: () => { }
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const copyToClipboard = async (text: string, label: string) => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(text);
|
|
||||||
} catch {
|
|
||||||
const textArea = document.createElement('textarea');
|
|
||||||
textArea.value = text;
|
|
||||||
document.body.appendChild(textArea);
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
document.body.removeChild(textArea);
|
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<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">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<VideoSkeleton v-if="loading" />
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<div v-else-if="video" class="flex flex-col lg:flex-row gap-4">
|
|
||||||
<VideoPlayer :video="video" class="lg:flex-1" />
|
|
||||||
|
|
||||||
<div class="bg-white rounded-lg border border-gray-200 max-w-full lg:max-w-md w-full flex flex-col">
|
|
||||||
<div class="px-6 py-4">
|
|
||||||
<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" />
|
|
||||||
<div class="mb-4">
|
|
||||||
<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>
|
|
||||||
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
|
||||||
<dd class="text-sm text-gray-900">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input readonly
|
|
||||||
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
|
||||||
:value="info.value || '-'">
|
|
||||||
<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="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">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ModelVideo } from '@/api/client';
|
import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
|
||||||
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
|
|
||||||
import { useAppToast } from '@/composables/useAppToast';
|
import { useAppToast } from '@/composables/useAppToast';
|
||||||
import { ref, watch } from 'vue';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -14,17 +14,35 @@ const emit = defineEmits<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const toast = useAppToast();
|
const toast = useAppToast();
|
||||||
|
const auth = useAuthStore();
|
||||||
const video = ref<ModelVideo | null>(null);
|
const video = ref<ModelVideo | null>(null);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
type AdConfigPayload = {
|
||||||
|
ad_template_id: string;
|
||||||
|
template_name?: string;
|
||||||
|
vast_tag_url?: string;
|
||||||
|
ad_format?: string;
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
const form = ref({
|
const form = ref({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
adTemplateId: '' as string,
|
||||||
});
|
});
|
||||||
|
|
||||||
const errors = ref<{ title?: string; description?: string }>({});
|
const currentAdConfig = ref<AdConfigPayload | null>(null);
|
||||||
|
const adTemplates = ref<ManualAdTemplate[]>([]);
|
||||||
|
const loadingTemplates = ref(false);
|
||||||
|
|
||||||
|
const errors = ref<{ title?: string }>({});
|
||||||
|
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||||
|
|
||||||
|
const activeTemplates = computed(() =>
|
||||||
|
adTemplates.value.filter(t => t.is_active),
|
||||||
|
);
|
||||||
|
|
||||||
const subtitleForm = ref({
|
const subtitleForm = ref({
|
||||||
file: null as File | null,
|
file: null as File | null,
|
||||||
@@ -32,15 +50,33 @@ const subtitleForm = ref({
|
|||||||
displayName: '',
|
displayName: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fetchAdTemplates = async () => {
|
||||||
|
loadingTemplates.value = true;
|
||||||
|
try {
|
||||||
|
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
|
||||||
|
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
|
||||||
|
adTemplates.value = items;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch ad templates:', error);
|
||||||
|
} finally {
|
||||||
|
loadingTemplates.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchVideo = async () => {
|
const fetchVideo = async () => {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const videoData = await fetchMockVideoById(props.videoId);
|
const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
|
||||||
|
const data = (response.data as any)?.data;
|
||||||
|
const videoData = data?.video || data;
|
||||||
|
const adConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||||
|
|
||||||
if (videoData) {
|
if (videoData) {
|
||||||
video.value = videoData;
|
video.value = videoData;
|
||||||
|
currentAdConfig.value = adConfig || null;
|
||||||
form.value = {
|
form.value = {
|
||||||
title: videoData.title || '',
|
title: videoData.title || '',
|
||||||
description: videoData.description || '',
|
adTemplateId: adConfig?.ad_template_id || '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -68,11 +104,27 @@ const onFormSubmit = async () => {
|
|||||||
if (!validate()) return;
|
if (!validate()) return;
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
await updateMockVideo(props.videoId, form.value);
|
const payload: Record<string, any> = {
|
||||||
|
title: form.value.title,
|
||||||
|
};
|
||||||
|
|
||||||
if (video.value) {
|
if (!isFreePlan.value) {
|
||||||
video.value.title = form.value.title;
|
payload.ad_template_id = form.value.adTemplateId || '';
|
||||||
video.value.description = form.value.description;
|
}
|
||||||
|
|
||||||
|
const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
|
||||||
|
|
||||||
|
const data = (response.data as any)?.data;
|
||||||
|
const updatedVideo = data?.video as ModelVideo | undefined;
|
||||||
|
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
|
||||||
|
|
||||||
|
if (updatedVideo) {
|
||||||
|
video.value = updatedVideo;
|
||||||
|
currentAdConfig.value = updatedAdConfig || null;
|
||||||
|
form.value = {
|
||||||
|
title: updatedVideo.title || '',
|
||||||
|
adTemplateId: updatedAdConfig?.ad_template_id || '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.add({
|
toast.add({
|
||||||
@@ -118,13 +170,14 @@ watch(() => props.videoId, (newId) => {
|
|||||||
if (newId) {
|
if (newId) {
|
||||||
errors.value = {};
|
errors.value = {};
|
||||||
fetchVideo();
|
fetchVideo();
|
||||||
|
fetchAdTemplates();
|
||||||
}
|
}
|
||||||
}, { immediate: true });
|
}, { immediate: true });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
|
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
|
||||||
:title="loading ? '' : t('video.detailModal.title')">
|
:title="loading ? '' : $t('video.detailModal.title')">
|
||||||
|
|
||||||
<!-- Loading Skeleton -->
|
<!-- Loading Skeleton -->
|
||||||
<div v-if="loading" class="flex flex-col gap-4">
|
<div v-if="loading" class="flex flex-col gap-4">
|
||||||
@@ -133,10 +186,10 @@ watch(() => props.videoId, (newId) => {
|
|||||||
<div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" />
|
<div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||||
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
|
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<!-- Description skeleton -->
|
<!-- ad-template selector skeleton -->
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
<div class="w-20 h-3.5 bg-gray-200 rounded animate-pulse" />
|
<div class="w-24 h-3.5 bg-gray-200 rounded animate-pulse" />
|
||||||
<div class="w-full h-24 bg-gray-200 rounded-md animate-pulse" />
|
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
<!-- Subtitles section skeleton -->
|
<!-- Subtitles section skeleton -->
|
||||||
<div class="flex flex-col gap-3 border-t border-gray-200 pt-4">
|
<div class="flex flex-col gap-3 border-t border-gray-200 pt-4">
|
||||||
@@ -171,27 +224,47 @@ watch(() => props.videoId, (newId) => {
|
|||||||
<p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
|
<p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Description -->
|
<!-- Ad Template Selector -->
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="edit-description" class="text-sm font-medium">{{ t('video.detailModal.descriptionLabel') }}</label>
|
<label for="edit-ad-template" class="text-sm font-medium">{{ t('video.detailModal.adTemplateLabel') }}</label>
|
||||||
<textarea id="edit-description" v-model="form.description" :placeholder="t('video.detailModal.descriptionPlaceholder')"
|
<select
|
||||||
rows="4"
|
id="edit-ad-template"
|
||||||
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" />
|
v-model="form.adTemplateId"
|
||||||
<p v-if="errors.description" class="text-xs text-red-500 mt-0.5">{{ errors.description }}</p>
|
:disabled="isFreePlan || saving"
|
||||||
|
class="w-full px-3 py-2 border rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
|
||||||
|
:class="isFreePlan
|
||||||
|
? 'border-border bg-muted/50 text-foreground/50 cursor-not-allowed'
|
||||||
|
: 'border-border bg-background text-foreground cursor-pointer hover:border-primary/50'"
|
||||||
|
>
|
||||||
|
<option value="">{{ t('video.detailModal.adTemplateNone') }}</option>
|
||||||
|
<option
|
||||||
|
v-for="tmpl in activeTemplates"
|
||||||
|
:key="tmpl.id"
|
||||||
|
:value="tmpl.id"
|
||||||
|
>
|
||||||
|
{{ tmpl.name }}{{ tmpl.is_default ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">
|
||||||
|
{{ t('video.detailModal.adTemplateUpgradeHint') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!form.adTemplateId" class="text-xs text-foreground/50 mt-0.5">
|
||||||
|
{{ t('video.detailModal.adTemplateNoAdsHint') }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Subtitles Section -->
|
<!-- Subtitles Section -->
|
||||||
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4">
|
<div class="flex flex-col gap-3 border-t-2 border-border pt-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<label class="text-sm font-medium">{{ t('video.detailModal.subtitlesTitle') }}</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">
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground/70">
|
||||||
{{ t('video.detailModal.subtitleTracks', { count: 0 }) }}
|
{{ t('video.detailModal.subtitleTracks', { count: 0 }) }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-sm text-muted-foreground">{{ t('video.detailModal.noSubtitles') }}</p>
|
<p class="text-sm text-muted-foreground">{{ t('video.detailModal.noSubtitles') }}</p>
|
||||||
|
|
||||||
<!-- Upload Subtitle Form -->
|
<!-- Upload Subtitle Form -->
|
||||||
<div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3">
|
<div class="flex flex-col gap-3 rounded-lg border border-border p-3">
|
||||||
<label class="text-sm font-medium">{{ t('video.detailModal.uploadSubtitle') }}</label>
|
<label class="text-sm font-medium">{{ t('video.detailModal.uploadSubtitle') }}</label>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -224,7 +297,7 @@ watch(() => props.videoId, (newId) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Footer inside Form so submit works -->
|
<!-- Footer inside Form so submit works -->
|
||||||
<div class="flex justify-end gap-2 border-t border-gray-200 pt-4">
|
<div class="flex justify-end gap-2 border-t border-border pt-4">
|
||||||
<AppButton variant="ghost" type="button" @click="emit('close')">{{ t('video.detailModal.cancel') }}</AppButton>
|
<AppButton variant="ghost" type="button" @click="emit('close')">{{ t('video.detailModal.cancel') }}</AppButton>
|
||||||
<AppButton type="submit" :loading="saving">{{ t('video.detailModal.saveChanges') }}</AppButton>
|
<AppButton type="submit" :loading="saving">{{ t('video.detailModal.saveChanges') }}</AppButton>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { type ModelVideo } from '@/api/client';
|
import { client, type ModelVideo } from '@/api/client';
|
||||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||||
import { fetchMockVideos } from '@/mocks/videos';
|
|
||||||
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
import { createStaticVNode, computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
@@ -48,25 +47,18 @@ const fetchVideos = async () => {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
try {
|
try {
|
||||||
// Attempt to fetch from API
|
const response = await client.videos.videosList({
|
||||||
// const response = await client.videos.videosList({ page: page.value, limit: limit.value });
|
|
||||||
// const body = response.data.data
|
|
||||||
|
|
||||||
// Use mock API
|
|
||||||
const response = await fetchMockVideos({
|
|
||||||
page: page.value,
|
page: page.value,
|
||||||
limit: limit.value,
|
limit: limit.value,
|
||||||
searchQuery: searchQuery.value,
|
search: searchQuery.value || undefined,
|
||||||
status: selectedStatus.value
|
status: selectedStatus.value !== 'all' ? selectedStatus.value : undefined,
|
||||||
});
|
} as any, { baseUrl: '/r' });
|
||||||
|
|
||||||
videos.value = response.data;
|
|
||||||
total.value = response.total;
|
|
||||||
|
|
||||||
|
videos.value = ((response.data as any)?.data?.videos ?? []) as ModelVideo[];
|
||||||
|
total.value = (response.data as any)?.data?.total ?? 0;
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
// Fallback to empty on error
|
error.value = err?.response?.data?.message || err?.message || t('video.page.retry');
|
||||||
console.log('Using mock data due to API error');
|
|
||||||
videos.value = [];
|
videos.value = [];
|
||||||
total.value = 0;
|
total.value = 0;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -84,11 +76,6 @@ const handleFilter = () => {
|
|||||||
fetchVideos();
|
fetchVideos();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
|
||||||
page.value = newPage;
|
|
||||||
fetchVideos();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Selection Logic
|
// Selection Logic
|
||||||
const selectedVideos = ref<ModelVideo[]>([]);
|
const selectedVideos = ref<ModelVideo[]>([]);
|
||||||
|
|
||||||
@@ -96,13 +83,22 @@ const deleteSelectedVideos = async () => {
|
|||||||
if (!selectedVideos.value.length || !confirm(t('video.page.deleteSelectedConfirm', { count: selectedVideos.value.length }))) return;
|
if (!selectedVideos.value.length || !confirm(t('video.page.deleteSelectedConfirm', { count: selectedVideos.value.length }))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Mock delete
|
await Promise.all(
|
||||||
const idsToDelete = selectedVideos.value.map(v => v.id);
|
selectedVideos.value
|
||||||
videos.value = videos.value.filter(v => v.id && !idsToDelete.includes(v.id));
|
.map(v => v.id)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
.map(id => client.videos.videosDelete(id, { baseUrl: '/r' }))
|
||||||
|
);
|
||||||
selectedVideos.value = [];
|
selectedVideos.value = [];
|
||||||
// In real app: await client.videos.bulkDelete(...) or loop
|
await fetchVideos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete videos', err);
|
console.error('Failed to delete videos', err);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('video.detailPage.toast.deleteErrorSummary'),
|
||||||
|
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -110,11 +106,17 @@ const deleteVideo = async (videoId?: string) => {
|
|||||||
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
|
if (!videoId || !confirm(t('video.page.deleteSingleConfirm'))) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
videos.value = videos.value.filter(v => v.id !== videoId);
|
await client.videos.videosDelete(videoId, { baseUrl: '/r' });
|
||||||
// If deleted video was in selection, remove it
|
|
||||||
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
selectedVideos.value = selectedVideos.value.filter(v => v.id !== videoId);
|
||||||
|
await fetchVideos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete video:', err);
|
console.error('Failed to delete video:', err);
|
||||||
|
toast.add({
|
||||||
|
severity: 'error',
|
||||||
|
summary: t('video.detailPage.toast.deleteErrorSummary'),
|
||||||
|
detail: t('video.detailPage.toast.deleteErrorDetail'),
|
||||||
|
life: 3000,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -130,11 +132,11 @@ watch(() => uiState.uploadDialogVisible, (visible) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
watch([searchQuery, selectedStatus, limit, page], () => {
|
watch([selectedStatus, limit, page], () => {
|
||||||
fetchVideos();
|
fetchVideos();
|
||||||
});
|
});
|
||||||
const editVideo = (videoId?: string) => {
|
const editVideo = (videoId?: string) => {
|
||||||
detailVideoId.value = videoId || '';
|
detailVideoId.value = videoId || "";
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyVideo = (videoId?: string) => {
|
const copyVideo = (videoId?: string) => {
|
||||||
@@ -257,7 +259,7 @@ onUnmounted(() => {
|
|||||||
<EmptyState v-else-if="videos.length === 0 && !loading" :title="t('video.page.emptyTitle')"
|
<EmptyState v-else-if="videos.length === 0 && !loading" :title="t('video.page.emptyTitle')"
|
||||||
:description="t('video.page.emptyDescription')"
|
:description="t('video.page.emptyDescription')"
|
||||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('video.page.emptyAction')"
|
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('video.page.emptyAction')"
|
||||||
:onAction="() => router.push('/upload')" />
|
:onAction="() => uiState.toggleUploadDialog()" />
|
||||||
<!-- Grid View -->
|
<!-- Grid View -->
|
||||||
<!-- <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" /> -->
|
<!-- <VideoGrid :videos="videos" :loading="loading" v-model:selectedVideos="selectedVideos" @delete="deleteVideo" v-else-if="viewMode === 'grid'" /> -->
|
||||||
|
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
saving: boolean;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:title': [value: string];
|
|
||||||
'update:description': [value: string];
|
|
||||||
save: [];
|
|
||||||
toggleEdit: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mb-4 space-y-3">
|
|
||||||
<div>
|
|
||||||
<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="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">{{ 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="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="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 ? t('video.detailPage.saving') : t('common.save') }}</span>
|
|
||||||
</AppButton>
|
|
||||||
|
|
||||||
<!-- Cancel Button (Edit Mode) -->
|
|
||||||
<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">{{ t('common.cancel') }}</span>
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
|
||||||
import { formatBytes, getStatusSeverity } from '@/lib/utils';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
video: ModelVideo;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
reload: [];
|
|
||||||
toggleEdit: [];
|
|
||||||
delete: [];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { t, i18next } = useTranslation();
|
|
||||||
|
|
||||||
const formatFileSize = (bytes?: number): string => {
|
|
||||||
if (!bytes) return '-';
|
|
||||||
return formatBytes(bytes);
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDuration = (seconds?: number): string => {
|
|
||||||
if (!seconds) return '-';
|
|
||||||
const hours = Math.floor(seconds / 3600);
|
|
||||||
const mins = Math.floor((seconds % 3600) / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDate = (dateStr?: string): string => {
|
|
||||||
if (!dateStr) return '-';
|
|
||||||
const date = new Date(dateStr);
|
|
||||||
return date.toLocaleString(i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US', {
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit'
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const severityClasses: Record<string, string> = {
|
|
||||||
success: 'bg-green-100 text-green-800',
|
|
||||||
info: 'bg-blue-100 text-blue-800',
|
|
||||||
warn: 'bg-yellow-100 text-yellow-800',
|
|
||||||
warning: 'bg-yellow-100 text-yellow-800',
|
|
||||||
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>
|
|
||||||
<div class="flex flex-col items-start justify-between mb-4 gap-4">
|
|
||||||
<div class="flex-1">
|
|
||||||
<!-- View Mode: Title -->
|
|
||||||
<div class="mb-2">
|
|
||||||
<h1 class="text-2xl font-bold text-gray-900 mb-1">
|
|
||||||
{{ video.title }}
|
|
||||||
</h1>
|
|
||||||
<p v-if="video.description" class="text-sm text-gray-600 whitespace-pre-wrap">{{ video.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<!-- Metadata -->
|
|
||||||
<div class="flex items-center space-x-4 text-sm text-gray-500">
|
|
||||||
<span>{{ formatDate(video.created_at) }}</span>
|
|
||||||
<span>{{ formatFileSize(video.size) }}</span>
|
|
||||||
<span>{{ formatDuration(video.duration) }}</span>
|
|
||||||
<span
|
|
||||||
class="capitalize px-2 py-0.5 text-xs font-medium rounded-full"
|
|
||||||
:class="severityClasses[getStatusSeverity(video.status) || 'secondary']">
|
|
||||||
{{ statusLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<AppButton size="sm" variant="secondary"
|
|
||||||
: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">{{ t('video.detailPage.reloadButton') }}</span>
|
|
||||||
</AppButton>
|
|
||||||
<AppButton size="sm" variant="ghost"
|
|
||||||
: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">{{ t('video.table.edit') }}</span>
|
|
||||||
</AppButton>
|
|
||||||
<AppButton variant="danger" size="sm"
|
|
||||||
: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">{{ t('video.table.delete') }}</span>
|
|
||||||
</AppButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
video: ModelVideo;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
copy: [text: string, label: string];
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
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 = `${origin.value}/embed/${props.video.id}`;
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ 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>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="">
|
|
||||||
<div class="mb-4">
|
|
||||||
<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>
|
|
||||||
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
|
|
||||||
<dd class="text-sm text-gray-900">
|
|
||||||
<div class="flex items-center space-x-2">
|
|
||||||
<input readonly
|
|
||||||
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
|
|
||||||
: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="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">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import type { ModelVideo } from '@/api/client';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
defineProps<{
|
|
||||||
video: ModelVideo;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="rounded-xl">
|
|
||||||
<div v-if="video.url" class="aspect-video rounded-xl bg-black overflow-hidden">
|
|
||||||
<video
|
|
||||||
:src="video.url"
|
|
||||||
controls
|
|
||||||
class="w-full h-full object-contain"
|
|
||||||
:poster="video.thumbnail">
|
|
||||||
{{ t('video.detailPage.videoTagFallback') }}
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
<div v-else class="w-full h-48 bg-gray-200 overflow-hidden flex-shrink-0">
|
|
||||||
<img
|
|
||||||
v-if="video.thumbnail"
|
|
||||||
:src="video.thumbnail"
|
|
||||||
:alt="video.title"
|
|
||||||
class="w-full h-full object-cover" />
|
|
||||||
<div v-else class="w-full h-full flex items-center justify-center">
|
|
||||||
<span class="i-heroicons-film text-gray-400 text-2xl" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="flex flex-col lg:flex-row gap-4">
|
|
||||||
<!-- Video Player Skeleton -->
|
|
||||||
<div class="md:flex-1 aspect-video rounded-xl bg-gray-200 animate-pulse" />
|
|
||||||
|
|
||||||
<!-- Info Card Skeleton -->
|
|
||||||
<div class="bg-white rounded-lg border border-gray-200 p-6 space-y-6">
|
|
||||||
<!-- Header Skeleton -->
|
|
||||||
<div class="flex items-start justify-between mb-4">
|
|
||||||
<div class="flex-1 space-y-3">
|
|
||||||
<div class="w-3/5 h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<div class="w-32 h-4 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-16 h-6 bg-gray-200 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-20 h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-16 h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-[4.5rem] h-8 bg-gray-200 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Content Grid Skeleton -->
|
|
||||||
<div class="grid grid-cols-1">
|
|
||||||
<!-- Left Column -->
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div class="w-full h-6 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div v-for="i in 6" :key="i" class="space-y-1">
|
|
||||||
<div class="w-full h-3.5 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<div class="w-full h-7 bg-gray-200 rounded animate-pulse" />
|
|
||||||
<div class="w-8 h-7 bg-gray-200 rounded animate-pulse" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,23 +1,50 @@
|
|||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
import { saveImageFromStream } from '../modules/merge';
|
import { getManifest, saveImageFromStream, streamManifest } from '../modules/merge';
|
||||||
|
|
||||||
|
const guessContentType = (filename: string) => {
|
||||||
|
const lower = filename.toLowerCase();
|
||||||
|
if (lower.endsWith('.mp4')) return 'video/mp4';
|
||||||
|
if (lower.endsWith('.webm')) return 'video/webm';
|
||||||
|
if (lower.endsWith('.mov')) return 'video/quicktime';
|
||||||
|
if (lower.endsWith('.mkv')) return 'video/x-matroska';
|
||||||
|
if (lower.endsWith('.m3u8')) return 'application/vnd.apple.mpegurl';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildStreamResponse = async (id: string) => {
|
||||||
|
const manifest = await getManifest(id);
|
||||||
|
if (!manifest) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Manifest not found' }), {
|
||||||
|
status: 404,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response(streamManifest(manifest), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': guessContentType(manifest.filename),
|
||||||
|
'Cache-Control': 'public, max-age=3600',
|
||||||
|
'Content-Disposition': `inline; filename="${manifest.filename}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export function registerDisplayRoutes(app: Hono) {
|
export function registerDisplayRoutes(app: Hono) {
|
||||||
// app.get('/manifest/:id', async (c) => {
|
app.get('/display/:id', async (c) => buildStreamResponse(c.req.param('id')));
|
||||||
// const manifest = await getListFiles();
|
app.get('/play/index/:id', async (c) => buildStreamResponse(c.req.param('id')));
|
||||||
// if (!manifest) {
|
|
||||||
// return c.json({ error: "Manifest not found" }, 404);
|
|
||||||
// }
|
|
||||||
// return c.json(manifest);
|
|
||||||
// });
|
|
||||||
app.put('/display/:id/thumbnail', async (c) => {
|
app.put('/display/:id/thumbnail', async (c) => {
|
||||||
const arrayBuffer = await c.req.arrayBuffer();
|
const arrayBuffer = await c.req.arrayBuffer();
|
||||||
await saveImageFromStream(arrayBuffer, crypto.randomUUID());
|
await saveImageFromStream(arrayBuffer, c.req.param('id'));
|
||||||
return c.body('ok');
|
return c.body('ok');
|
||||||
// nhận rawData, lưu vào storage, cập nhật url thumbnail vào database
|
|
||||||
|
|
||||||
});
|
});
|
||||||
app.put('/display/:id/metadata', async (c) => {
|
|
||||||
|
|
||||||
|
app.put('/display/:id/metadata', async (c) => {
|
||||||
|
return c.json({ status: 'not_implemented' }, 501);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/display/:id/subs', async (c) => {
|
||||||
|
return c.json({ status: 'not_implemented' }, 501);
|
||||||
});
|
});
|
||||||
app.post('/display/:id/subs', async (c) => {});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { getListFiles } from '@/server/modules/merge';
|
import { getManifest } from '@/server/modules/merge';
|
||||||
import type { Hono } from 'hono';
|
import type { Hono } from 'hono';
|
||||||
|
|
||||||
export function registerManifestRoutes(app: Hono) {
|
export function registerManifestRoutes(app: Hono) {
|
||||||
app.get('/manifest/:id', async (c) => {
|
app.get('/manifest/:id', async (c) => {
|
||||||
const manifest = await getListFiles();
|
const manifest = await getManifest(c.req.param('id'));
|
||||||
if (!manifest) {
|
if (!manifest) {
|
||||||
return c.json({ error: "Manifest not found" }, 404);
|
return c.json({ error: 'Manifest not found' }, 404);
|
||||||
}
|
}
|
||||||
return c.json(manifest);
|
return c.json(manifest);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export function registerMergeRoutes(app: Hono) {
|
|||||||
filename: manifest.filename,
|
filename: manifest.filename,
|
||||||
total_parts: manifest.total_parts,
|
total_parts: manifest.total_parts,
|
||||||
size: manifest.size,
|
size: manifest.size,
|
||||||
|
playback_url: `/display/${manifest.id}`,
|
||||||
|
play_url: `/play/index/${manifest.id}`,
|
||||||
|
manifest_url: `/manifest/${manifest.id}`,
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
return c.json({ error: e?.message ?? String(e) }, 500);
|
return c.json({ error: e?.message ?? String(e) }, 500);
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export function registerSSRRoutes(app: Hono) {
|
|||||||
const appStream = renderToWebStream(vueApp, ctx);
|
const appStream = renderToWebStream(vueApp, ctx);
|
||||||
|
|
||||||
// HTML Head
|
// HTML Head
|
||||||
await stream.write(`<!DOCTYPE html><html lang='${lang}'><head>`);
|
await stream.write(`<!DOCTYPE html><html lang='${auth.user?.language ?? lang}'><head>`);
|
||||||
await stream.write("<base href='" + url.origin + "'/>");
|
await stream.write("<base href='" + url.origin + "'/>");
|
||||||
|
|
||||||
// SSR Head tags
|
// SSR Head tags
|
||||||
@@ -63,7 +63,7 @@ export function registerSSRRoutes(app: Hono) {
|
|||||||
Object.assign(ctx, {
|
Object.assign(ctx, {
|
||||||
$p: pinia.state.value,
|
$p: pinia.state.value,
|
||||||
$colada: serializeQueryCache(queryCache),
|
$colada: serializeQueryCache(queryCache),
|
||||||
$locale: lang,
|
$locale: auth.user?.language ?? lang,
|
||||||
});
|
});
|
||||||
|
|
||||||
// App data script
|
// App data script
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { client, type ModelUser, type ResponseResponse } from '@/api/client';
|
import { client, type AuthUserPayload, type ResponseResponse } from '@/api/client';
|
||||||
import { TinyMqttClient } from '@/lib/liteMqtt';
|
import { TinyMqttClient } from '@/lib/liteMqtt';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useTranslation } from 'i18next-vue';
|
||||||
import { defineStore } from 'pinia';
|
import { defineStore } from 'pinia';
|
||||||
@@ -7,18 +7,17 @@ import { useRouter } from 'vue-router';
|
|||||||
|
|
||||||
type ProfileUpdatePayload = {
|
type ProfileUpdatePayload = {
|
||||||
username?: string;
|
username?: string;
|
||||||
email?: string;
|
|
||||||
language?: string;
|
language?: string;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthResponseBody = ResponseResponse & {
|
type AuthResponseBody = ResponseResponse & {
|
||||||
data?: ModelUser | { user?: ModelUser };
|
data?: AuthUserPayload | { user?: AuthUserPayload };
|
||||||
};
|
};
|
||||||
|
|
||||||
const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt';
|
const mqttBrokerUrl = 'wss://mqtt-dashboard.com:8884/mqtt';
|
||||||
|
|
||||||
const extractUser = (body?: AuthResponseBody | null): ModelUser | null => {
|
const extractUser = (body?: AuthResponseBody | null): AuthUserPayload | null => {
|
||||||
const data = body?.data;
|
const data = body?.data;
|
||||||
|
|
||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
@@ -26,7 +25,7 @@ const extractUser = (body?: AuthResponseBody | null): ModelUser | null => {
|
|||||||
return data.user;
|
return data.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
return data as ModelUser;
|
return data as AuthUserPayload;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getGoogleLoginPath = () => {
|
const getGoogleLoginPath = () => {
|
||||||
@@ -35,9 +34,9 @@ const getGoogleLoginPath = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<ModelUser | null>(null);
|
const user = ref<AuthUserPayload | null>(null);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { t } = useTranslation();
|
const { t, i18next } = useTranslation();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const initialized = ref(false);
|
const initialized = ref(false);
|
||||||
@@ -61,7 +60,6 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
|
|
||||||
clearMqttClient();
|
clearMqttClient();
|
||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
|
|
||||||
mqttClient = new TinyMqttClient(
|
mqttClient = new TinyMqttClient(
|
||||||
mqttBrokerUrl,
|
mqttBrokerUrl,
|
||||||
[['ecos1231231', userId, '#'].join('/')],
|
[['ecos1231231', userId, '#'].join('/')],
|
||||||
@@ -71,21 +69,21 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
);
|
);
|
||||||
mqttClient.connect();
|
mqttClient.connect();
|
||||||
});
|
});
|
||||||
|
watch(() => user.value?.language, (lng) => i18next.changeLanguage(lng))
|
||||||
|
async function fetchMe() {
|
||||||
|
const response = await client.me.getMe({ baseUrl: '/r' });
|
||||||
|
|
||||||
|
const nextUser = extractUser(response.data as AuthResponseBody);
|
||||||
|
user.value = nextUser;
|
||||||
|
i18next.changeLanguage(nextUser?.language)
|
||||||
|
return nextUser;
|
||||||
|
}
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
if (initialized.value) return;
|
if (initialized.value) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await client.request<AuthResponseBody, ResponseResponse>({
|
await fetchMe();
|
||||||
path: '/me',
|
|
||||||
method: 'GET',
|
|
||||||
format: 'json',
|
|
||||||
});
|
|
||||||
|
|
||||||
const nextUser = extractUser(response.data as AuthResponseBody);
|
|
||||||
if (nextUser) {
|
|
||||||
user.value = nextUser;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
user.value = null;
|
user.value = null;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -149,16 +147,11 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await client.request<AuthResponseBody, ResponseResponse>({
|
const response = await client.me.putMe(data, { baseUrl: '/r' });
|
||||||
path: '/me',
|
|
||||||
method: 'PUT',
|
|
||||||
body: data,
|
|
||||||
format: 'json',
|
|
||||||
});
|
|
||||||
const nextUser = extractUser(response.data as AuthResponseBody);
|
const nextUser = extractUser(response.data as AuthResponseBody);
|
||||||
|
|
||||||
if (nextUser) {
|
if (nextUser) {
|
||||||
user.value = { ...(user.value ?? {}), ...nextUser } as ModelUser;
|
user.value = { ...(user.value ?? {}), ...nextUser } as AuthUserPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
@@ -189,15 +182,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
error.value = null;
|
error.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.request<ResponseResponse, ResponseResponse>({
|
await client.auth.changePasswordCreate({
|
||||||
path: '/auth/change-password',
|
current_password: currentPassword,
|
||||||
method: 'POST',
|
new_password: newPassword,
|
||||||
body: {
|
}, { baseUrl: '/r' });
|
||||||
current_password: currentPassword,
|
|
||||||
new_password: newPassword,
|
|
||||||
},
|
|
||||||
format: 'json',
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error('Change password error', e);
|
console.error('Change password error', e);
|
||||||
@@ -230,6 +218,7 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
error,
|
error,
|
||||||
initialized,
|
initialized,
|
||||||
init,
|
init,
|
||||||
|
fetchMe,
|
||||||
login,
|
login,
|
||||||
loginWithGoogle,
|
loginWithGoogle,
|
||||||
register,
|
register,
|
||||||
|
|||||||
8
src/type.d.ts
vendored
8
src/type.d.ts
vendored
@@ -1,6 +1,12 @@
|
|||||||
/// <reference types="vite/client" />
|
/// <reference types="vite/client" />
|
||||||
/// <reference types="unplugin-vue-components/types/vue" />
|
/// <reference types="unplugin-vue-components/types/vue" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue';
|
||||||
|
const component: DefineComponent<{}, {}, any>;
|
||||||
|
export default component;
|
||||||
|
}
|
||||||
|
|
||||||
declare module "@httpClientAdapter" {
|
declare module "@httpClientAdapter" {
|
||||||
export const customFetch: (url: string, options: RequestInit) => Promise<Response>;
|
export const customFetch: typeof fetch;
|
||||||
}
|
}
|
||||||
27
ssrPlugin.ts
27
ssrPlugin.ts
@@ -32,10 +32,11 @@ export function clientFirstBuild(): Plugin {
|
|||||||
// Client build first
|
// Client build first
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
if (clientEnvironment) {
|
if (clientEnvironment) {
|
||||||
// console.log("Client First Build Plugin: Building client...", clientEnvironment.resolve);
|
// clientEnvironment.config.build.outDir = "dist/client";
|
||||||
|
// console.log("Client First Build Plugin: Building client...", Object.keys());
|
||||||
await builder.build(clientEnvironment);
|
await builder.build(clientEnvironment);
|
||||||
}
|
}
|
||||||
|
// console.log("Client First Build Plugin: Client build complete.", workerEnvironments);
|
||||||
// Then worker builds
|
// Then worker builds
|
||||||
for (const workerEnv of workerEnvironments) {
|
for (const workerEnv of workerEnvironments) {
|
||||||
await builder.build(workerEnv);
|
await builder.build(workerEnv);
|
||||||
@@ -109,23 +110,14 @@ export default function ssrPlugin(): Plugin[] {
|
|||||||
config.define = config.define || {};
|
config.define = config.define || {};
|
||||||
},
|
},
|
||||||
resolveId(id, importer, options) {
|
resolveId(id, importer, options) {
|
||||||
if (!['@httpClientAdapter', '@liteMqtt'].includes(id)) return
|
if (!id.startsWith('@httpClientAdapter')) return
|
||||||
switch (id) {
|
const pwd = process.cwd()
|
||||||
case '@httpClientAdapter':
|
|
||||||
return path.resolve(
|
return path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
options?.ssr
|
options?.ssr
|
||||||
? "./src/api/httpClientAdapter.server.ts"
|
? pwd+"/src/api/httpClientAdapter.server.ts"
|
||||||
: "./src/api/httpClientAdapter.client.ts"
|
: pwd+"/src/api/httpClientAdapter.client.ts"
|
||||||
);
|
);
|
||||||
case '@liteMqtt':
|
|
||||||
return path.resolve(
|
|
||||||
__dirname,
|
|
||||||
options?.ssr
|
|
||||||
? "./src/lib/liteMqtt.server.ts"
|
|
||||||
: "./src/lib/liteMqtt.ts"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
async configResolved(config) {
|
async configResolved(config) {
|
||||||
const viteConfig = config as any;
|
const viteConfig = config as any;
|
||||||
@@ -143,7 +135,8 @@ export default function ssrPlugin(): Plugin[] {
|
|||||||
const clientBuild = viteConfig.environments.client.build;
|
const clientBuild = viteConfig.environments.client.build;
|
||||||
clientBuild.manifest = true;
|
clientBuild.manifest = true;
|
||||||
clientBuild.rollupOptions = clientBuild.rollupOptions || {};
|
clientBuild.rollupOptions = clientBuild.rollupOptions || {};
|
||||||
clientBuild.rollupOptions.input = "src/client.ts";
|
// clientBuild.rollupOptions.input = "src/client.ts";
|
||||||
|
// clientBuild.outDir = "dist/client";
|
||||||
if (!viteConfig.environments.ssr) {
|
if (!viteConfig.environments.ssr) {
|
||||||
const manifestPath = path.join(clientBuild.outDir as string, '.vite/manifest.json')
|
const manifestPath = path.join(clientBuild.outDir as string, '.vite/manifest.json')
|
||||||
try {
|
try {
|
||||||
@@ -159,4 +152,4 @@ export default function ssrPlugin(): Plugin[] {
|
|||||||
plugins.push(injectManifest());
|
plugins.push(injectManifest());
|
||||||
|
|
||||||
return plugins;
|
return plugins;
|
||||||
}
|
}
|
||||||
@@ -27,13 +27,13 @@ export default defineConfig({
|
|||||||
theme: {
|
theme: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: {
|
primary: {
|
||||||
DEFAULT: "#14a74b",
|
DEFAULT: "#4563ca",
|
||||||
50: "#effcf3",
|
50: "#effcf3",
|
||||||
100: "#dcf9e2",
|
100: "#dcf9e2",
|
||||||
200: "#bbf0c8",
|
200: "#bbf0c8",
|
||||||
300: "#86efac",
|
300: "#86efac",
|
||||||
400: "#4ade80",
|
400: "#4ade80",
|
||||||
500: "#14a74b",
|
500: "#4563ca",
|
||||||
600: "#16a34a",
|
600: "#16a34a",
|
||||||
700: "#15803d",
|
700: "#15803d",
|
||||||
800: "#166534",
|
800: "#166534",
|
||||||
@@ -178,10 +178,6 @@ export default defineConfig({
|
|||||||
DEFAULT: "#fafafa",
|
DEFAULT: "#fafafa",
|
||||||
light: "#f8f9fa",
|
light: "#f8f9fa",
|
||||||
},
|
},
|
||||||
muted: {
|
|
||||||
DEFAULT: "#f5f4f2",
|
|
||||||
light: "#f8f9fa",
|
|
||||||
},
|
|
||||||
border: {
|
border: {
|
||||||
DEFAULT: "#e6e7e2",
|
DEFAULT: "#e6e7e2",
|
||||||
light: "#f8f9fa",
|
light: "#f8f9fa",
|
||||||
|
|||||||
118
vite-plugin-ssr-middleware.ts
Normal file
118
vite-plugin-ssr-middleware.ts
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
import type { Connect, Plugin } from "vite";
|
||||||
|
import { name as packageName } from "./package.json";
|
||||||
|
import { createMiddleware } from "@hattip/adapter-node";
|
||||||
|
import { pathToFileURL } from "url";
|
||||||
|
|
||||||
|
|
||||||
|
export function vitePluginSsrMiddleware({
|
||||||
|
entry,
|
||||||
|
preview,
|
||||||
|
mode = "ssrLoadModule",
|
||||||
|
}: {
|
||||||
|
entry: string;
|
||||||
|
preview?: string;
|
||||||
|
mode?: "ssrLoadModule" | "ModuleRunner" | "ModuleRunner-HMR";
|
||||||
|
}): Plugin {
|
||||||
|
return {
|
||||||
|
name: packageName,
|
||||||
|
|
||||||
|
apply(config, env) {
|
||||||
|
// skip client build
|
||||||
|
return Boolean(env.command === "serve" || config.build?.ssr);
|
||||||
|
},
|
||||||
|
|
||||||
|
config(config, env) {
|
||||||
|
if (env.command === "serve") {
|
||||||
|
return {
|
||||||
|
// disable builtin HTML middleware, which would rewrite `req.url` to "/index.html"
|
||||||
|
appType: "custom",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (env.command === "build" && config.build?.ssr) {
|
||||||
|
return {
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
index: entry,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
|
||||||
|
async configureServer(server) {
|
||||||
|
let loadModule = server.ssrLoadModule;
|
||||||
|
if (mode === "ModuleRunner" || mode === "ModuleRunner-HMR") {
|
||||||
|
const { createServerModuleRunner } = await import("vite");
|
||||||
|
const runner = createServerModuleRunner(server.environments.ssr, {
|
||||||
|
hmr: mode === "ModuleRunner-HMR" ? undefined : false,
|
||||||
|
});
|
||||||
|
loadModule = (id: string) => runner.import(id);
|
||||||
|
}
|
||||||
|
// const mod = await loadModule(entry);
|
||||||
|
const handler: Connect.NextHandleFunction = async (req, res, next) => {
|
||||||
|
console.log("vite-plugin-ssr-middleware handling request:", req.method, req.url);
|
||||||
|
// expose ViteDevServer via request
|
||||||
|
Object.defineProperty(req, "viteDevServer", { value: server });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mod = await loadModule(entry);
|
||||||
|
// console.log("preview module loaded:", mod);
|
||||||
|
await createMiddleware((ctx) => mod["default"].fetch(ctx.request))(req, res, next);
|
||||||
|
// await mod["default"](req, res, next);
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => server.middlewares.use(handler);
|
||||||
|
},
|
||||||
|
|
||||||
|
async configurePreviewServer(server) {
|
||||||
|
if (preview) {
|
||||||
|
const mod = await import( pathToFileURL(preview).href);
|
||||||
|
return () => server.middlewares.use(createMiddleware((ctx) => mod["default"].fetch(ctx.request)));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// minimal logger inspired by
|
||||||
|
// https://github.com/koajs/logger
|
||||||
|
// https://github.com/honojs/hono/blob/25beca878f2662fedd84ed3fbf80c6a515609cea/src/middleware/logger/index.ts
|
||||||
|
|
||||||
|
export function vitePluginLogger(): Plugin {
|
||||||
|
return {
|
||||||
|
name: vitePluginLogger.name,
|
||||||
|
configureServer(server) {
|
||||||
|
return () => server.middlewares.use(loggerMiddleware());
|
||||||
|
},
|
||||||
|
configurePreviewServer(server) {
|
||||||
|
return () => server.middlewares.use(loggerMiddleware());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loggerMiddleware(): Connect.NextHandleFunction {
|
||||||
|
return (req, res, next) => {
|
||||||
|
const url = new URL(req.originalUrl!, "https://test.local");
|
||||||
|
console.log(" -->", req.method, url.pathname);
|
||||||
|
const startTime = Date.now();
|
||||||
|
res.once("close", () => {
|
||||||
|
console.log(
|
||||||
|
" <--",
|
||||||
|
req.method,
|
||||||
|
url.pathname,
|
||||||
|
res.statusCode,
|
||||||
|
formatDuration(Date.now() - startTime),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms: number) {
|
||||||
|
return ms < 1000 ? `${Math.floor(ms)}ms` : `${(ms / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { cloudflare } from "@cloudflare/vite-plugin";
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import vueJsx from "@vitejs/plugin-vue-jsx";
|
import vueJsx from "@vitejs/plugin-vue-jsx";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
@@ -7,12 +6,13 @@ import Components from "unplugin-vue-components/vite";
|
|||||||
import AutoImport from "unplugin-auto-import/vite";
|
import AutoImport from "unplugin-auto-import/vite";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import ssrPlugin from "./ssrPlugin";
|
import ssrPlugin from "./ssrPlugin";
|
||||||
|
import { vitePluginSsrMiddleware } from "./vite-plugin-ssr-middleware";
|
||||||
export default defineConfig((env) => {
|
export default defineConfig((env) => {
|
||||||
// console.log("env:", env, import.meta.env);
|
// console.log("env:", env, import.meta.env);
|
||||||
return {
|
return {
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0'
|
host: '0.0.0.0'
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
unocss(),
|
unocss(),
|
||||||
vue(),
|
vue(),
|
||||||
@@ -29,8 +29,30 @@ export default defineConfig((env) => {
|
|||||||
directives: false,
|
directives: false,
|
||||||
}),
|
}),
|
||||||
ssrPlugin(),
|
ssrPlugin(),
|
||||||
cloudflare(),
|
vitePluginSsrMiddleware({
|
||||||
|
entry: "/src/index.tsx",
|
||||||
|
preview: path.resolve("dist/server/index.js"),
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
|
environments: {
|
||||||
|
client: {
|
||||||
|
build: {
|
||||||
|
outDir: "dist/client",
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: "/src/client.ts" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
build: {
|
||||||
|
outDir: "dist/server",
|
||||||
|
copyPublicDir: false,
|
||||||
|
rollupOptions: {
|
||||||
|
input: { index: "/src/index.tsx" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": path.resolve(__dirname, "./src"),
|
||||||
@@ -41,8 +63,8 @@ export default defineConfig((env) => {
|
|||||||
exclude: ["vue"],
|
exclude: ["vue"],
|
||||||
},
|
},
|
||||||
|
|
||||||
ssr: {
|
// ssr: {
|
||||||
noExternal: ["vue"],
|
// noExternal: ["vue"],
|
||||||
},
|
// },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user