Compare commits
16 Commits
develop-i1
...
develop-gr
| Author | SHA1 | Date | |
|---|---|---|---|
| b60f65e4d1 | |||
| e854c68ad0 | |||
| b787cd161a | |||
| bd8b21955e | |||
| 87c99e64cd | |||
| baa8811e9e | |||
| fa88fe26b3 | |||
| 90d8409aa9 | |||
| b4bbacd9f1 | |||
| 8b85736903 | |||
| 3beabcfe7f | |||
| 35117b7be9 | |||
| e3587eff71 | |||
| 57903b80b6 | |||
| 5c0ca0e139 | |||
| 9276603a70 |
@@ -5,7 +5,14 @@
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(bun install:*)",
|
||||
"Bash(bun preview:*)",
|
||||
"Bash(curl:*)"
|
||||
"Bash(curl:*)",
|
||||
"Bash(python -:*)",
|
||||
"Bash(bun run:*)",
|
||||
"Bash(bunx:*)",
|
||||
"Bash(bun:*)",
|
||||
"Bash(git diff:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git status:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
68
.dockerignore
Normal file
68
.dockerignore
Normal file
@@ -0,0 +1,68 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.rsbuild
|
||||
node_modules
|
||||
/node_modules
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# IDE and editor files
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Test files
|
||||
coverage
|
||||
.coverage
|
||||
.nyc_output
|
||||
test
|
||||
tests
|
||||
__tests__
|
||||
*.test.js
|
||||
*.test.ts
|
||||
*.spec.js
|
||||
*.spec.ts
|
||||
|
||||
# Linting
|
||||
.eslintrc*
|
||||
.prettierrc*
|
||||
.stylelintrc*
|
||||
|
||||
# Other
|
||||
.husky
|
||||
39
Dockerfile
Normal file
39
Dockerfile
Normal file
@@ -0,0 +1,39 @@
|
||||
# ---------- Builder stage ----------
|
||||
FROM oven/bun:1.3.10-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy lockfiles & package.json
|
||||
COPY package*.json ./
|
||||
COPY bun.lockb* ./
|
||||
COPY yarn.lock* ./
|
||||
COPY pnpm-lock.yaml* ./
|
||||
|
||||
# Install dependencies (cached)
|
||||
RUN --mount=type=cache,target=/root/.bun bun install
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
# Build app (RSBuild output -> dist)
|
||||
RUN bun run build
|
||||
|
||||
|
||||
# ---------- Production stage ----------
|
||||
FROM oven/bun:1.3.10-alpine AS production
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
ENV NODE_ENV=production
|
||||
# Expose port
|
||||
EXPOSE 3000
|
||||
|
||||
# Optional health check
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||
|
||||
# Run Bun with fallback install (auto resolves missing deps)
|
||||
CMD [ "bun", "--bun", "dist" ]
|
||||
19
buf.gen.yaml
Normal file
19
buf.gen.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
version: v2
|
||||
plugins:
|
||||
# - remote: buf.build/protocolbuffers/go
|
||||
# out: internal/gen/proto
|
||||
# opt:
|
||||
# - paths=source_relative
|
||||
# - remote: buf.build/grpc/go
|
||||
# out: internal/gen/proto
|
||||
# opt:
|
||||
# - paths=source_relative
|
||||
- remote: buf.build/community/stephenh-ts-proto
|
||||
out: ./src/server/utils/proto
|
||||
opt:
|
||||
- env=node
|
||||
- esModuleInterop=true
|
||||
- outputServices=grpc-js
|
||||
- useOptionals=all
|
||||
- forceLong=number
|
||||
- useDate=string
|
||||
9
buf.yaml
Normal file
9
buf.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
version: v2
|
||||
modules:
|
||||
- path: proto
|
||||
lint:
|
||||
use:
|
||||
- STANDARD
|
||||
breaking:
|
||||
use:
|
||||
- FILE
|
||||
303
bun.lock
303
bun.lock
@@ -5,35 +5,42 @@
|
||||
"": {
|
||||
"name": "holistream",
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@hattip/adapter-node": "^0.0.49",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@pinia/colada": "^0.21.7",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.12.5",
|
||||
"i18next": "^25.8.14",
|
||||
"hono": "^4.12.7",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.29",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3",
|
||||
"zod": "^4.3.6",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.26.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.5",
|
||||
"estree-walker": "2.0.2",
|
||||
"unocss": "^66.6.6",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.70.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -94,23 +101,7 @@
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="],
|
||||
|
||||
"@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.14.0", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": "^1.20260218.0" }, "optionalPeers": ["workerd"] }, "sha512-XKAkWhi1nBdNsSEoNG9nkcbyvfUrSjSf+VYVPfOto3gLTZVc3F4g6RASCMh6IixBKCG2yDgZKQIHGKtjcnLnKg=="],
|
||||
|
||||
"@cloudflare/vite-plugin": ["@cloudflare/vite-plugin@1.26.0", "", { "dependencies": { "@cloudflare/unenv-preset": "2.14.0", "miniflare": "4.20260301.1", "unenv": "2.0.0-rc.24", "wrangler": "4.70.0", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-F5jSOj9JeWMp9iQa2x+Ocjz++SCfK6Phcca/YLkaddPw5ie7W1VvEWudQ/gxYtRd47mQ/PfCLkE9QGyy6OGEng=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260301.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-+kJvwociLrvy1JV9BAvoSVsMEIYD982CpFmo/yMEvBwxDIjltYsLTE8DLi0mCkGsQ8Ygidv2fD9wavzXeiY7OQ=="],
|
||||
|
||||
"@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260301.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PPIetY3e67YBr9O4UhILK8nbm5TqUDl14qx4rwFNrRSBOvlzuczzbd4BqgpAtbGVFxKp1PWpjAnBvGU/OI/tLQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260301.1", "", { "os": "linux", "cpu": "x64" }, "sha512-Gu5vaVTZuYl3cHa+u5CDzSVDBvSkfNyuAHi6Mdfut7TTUdcb3V5CIcR/mXRSyMXzEy9YxEWIfdKMxOMBjupvYQ=="],
|
||||
|
||||
"@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260301.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-igL1pkyCXW6GiGpjdOAvqMi87UW0LMc/+yIQe/CSzuZJm5GzXoAMrwVTkCFnikk6JVGELrM5x0tGYlxa0sk5Iw=="],
|
||||
|
||||
"@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260301.1", "", { "os": "win32", "cpu": "x64" }, "sha512-Q0wMJ4kcujXILwQKQFc1jaYamVsNvjuECzvRrTI8OxGFMx2yq9aOsswViE4X1gaS2YQQ5u0JGwuGi5WdT1Lt7A=="],
|
||||
|
||||
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
|
||||
"@bufbuild/protobuf": ["@bufbuild/protobuf@2.11.0", "", {}, "sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
|
||||
|
||||
@@ -170,6 +161,10 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
|
||||
|
||||
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="],
|
||||
|
||||
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -180,62 +175,18 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@hiogawa/tiny-rpc": ["@hiogawa/tiny-rpc@0.2.3-pre.18", "", {}, "sha512-BiNHrutG9G9yV622QvkxZxF+PhkaH2Aspp4/X1KYTfnaQTcg4fFUTBWf5Kf533swon2SuVJwi6U6H1LQbhVOQQ=="],
|
||||
|
||||
"@hiogawa/utils": ["@hiogawa/utils@1.7.0", "", {}, "sha512-ghiEFWBR1NENoHn+lSuW7liicTIzVPN+8Srm5UedCTw43gus0mlse6Wp2lz6GmbOXJ/CalMPp/0Tz2X8tajkAg=="],
|
||||
|
||||
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||
|
||||
"@hono/zod-validator": ["@hono/zod-validator@0.7.6", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Io1B6d011Gj1KknV4rXYz4le5+5EubcWEU/speUjuw9XMMIaP3n78yXLhjd2A3PXaXaUwEAluOiAyLqhBEJgsw=="],
|
||||
|
||||
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="],
|
||||
|
||||
"@iconify/utils": ["@iconify/utils@3.1.0", "", { "dependencies": { "@antfu/install-pkg": "^1.1.0", "@iconify/types": "^2.0.0", "mlly": "^1.8.0" } }, "sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw=="],
|
||||
|
||||
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
|
||||
|
||||
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
|
||||
|
||||
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="],
|
||||
|
||||
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="],
|
||||
|
||||
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="],
|
||||
|
||||
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="],
|
||||
|
||||
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="],
|
||||
|
||||
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="],
|
||||
|
||||
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="],
|
||||
|
||||
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="],
|
||||
|
||||
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="],
|
||||
|
||||
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="],
|
||||
|
||||
"@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="],
|
||||
|
||||
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="],
|
||||
|
||||
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="],
|
||||
|
||||
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="],
|
||||
|
||||
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="],
|
||||
|
||||
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="],
|
||||
|
||||
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="],
|
||||
|
||||
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="],
|
||||
|
||||
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
@@ -246,6 +197,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=="],
|
||||
|
||||
"@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -294,15 +247,29 @@
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
|
||||
|
||||
"@pinia/colada": ["@pinia/colada@0.21.7", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-b8dJgRSjh7o6NnPXuvMbqv6JhoD/m/CwdadKl5SQvygsbUveYCBoqtnWzPch8AEW/UK0I3rFoATE8WrfI2cgKA=="],
|
||||
"@pinia/colada": ["@pinia/colada@1.0.0", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-YKSybA6wusFK4CAUPzItoSgPCfScVnnnO2MSlmaaisE/L7luE77GxFyhTzipM8IbvbXh4zkCy97OE7w9WX34wA=="],
|
||||
|
||||
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
|
||||
|
||||
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="],
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="],
|
||||
|
||||
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
|
||||
|
||||
@@ -334,61 +301,63 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
|
||||
|
||||
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
|
||||
"@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="],
|
||||
|
||||
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
|
||||
"@tanstack/vue-table": ["@tanstack/vue-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "vue": ">=3.2" } }, "sha512-rusRyd77c5tDPloPskctMyPLFEQUeBzxdQ+2Eow4F7gDPlPOB1UnnhzfpdvqZ8ZyX2rRNGmqNnQWm87OI2OQPw=="],
|
||||
|
||||
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@types/node": ["@types/node@25.3.3", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ=="],
|
||||
|
||||
"@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
|
||||
|
||||
"@unhead/vue": ["@unhead/vue@2.1.10", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.10" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-VP78Onh2HNezLPfhYjfHqn4dxlcQsE6PJgTTs61NksO/thvilNswtgBq0N0MWCLtn43N5akEPGW2y2zxM3PWgQ=="],
|
||||
"@unhead/vue": ["@unhead/vue@2.1.12", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.12" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-zEWqg0nZM8acpuTZE40wkeUl8AhIe0tU0OkilVi1D4fmVjACrwoh5HP6aNqJ8kUnKsoy6D+R3Vi/O+fmdNGO7g=="],
|
||||
|
||||
"@unocss/cli": ["@unocss/cli@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-directives": "66.6.5", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-UlETATpAZ+A5gOfj+z+BMXuIUcXCMjvlQteQE0VR2Yf0VIxz4sVO4z0VCXwXsxLTMfQiIMDpKVrGeczcYicvTA=="],
|
||||
"@unocss/cli": ["@unocss/cli@66.6.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.6", "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6", "@unocss/preset-wind4": "66.6.6", "@unocss/transformer-directives": "66.6.6", "cac": "^6.7.14", "chokidar": "^5.0.0", "colorette": "^2.0.20", "consola": "^3.4.2", "magic-string": "^0.30.21", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "bin": { "unocss": "bin/unocss.mjs" } }, "sha512-78SY8j4hAVelK+vP/adsDGaSjEITasYLFECJLHWxUJSzK+G9UIc5wtL/u4jA+zKvwVkHcDvbkcO5K6wwwpAixg=="],
|
||||
|
||||
"@unocss/config": ["@unocss/config@66.6.4", "", { "dependencies": { "@unocss/core": "66.6.4", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-iwHl5FG81cOAMalqigjw21Z2tMa0xjN0doQxnGOLx8KP+BllruXSjBj8CRk3m6Ny9fDxfpFY0ruYbIBA5AGwDQ=="],
|
||||
"@unocss/config": ["@unocss/config@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-menlnkqAFX/4wR2aandY8hSqrt01JE+rOzvtQxWaBt8kf1du62b0sS72FE5Z40n6HlEsEbF91N9FCfhnzG6i6g=="],
|
||||
|
||||
"@unocss/core": ["@unocss/core@66.6.5", "", {}, "sha512-hzjo+0EF+pNbf+tb0OjRNZRF9BJoKECcZZgtufxRPpWJdlv+aYmNkH1p9fldlHHzYcn3ZqVnnHnmk7HwaolJbg=="],
|
||||
"@unocss/core": ["@unocss/core@66.6.6", "", {}, "sha512-Sbbx0ZQqmV8K2lg8E+z9MJzWb1MgRtJnvqzxDIrNuBjXasKhbcFt5wEMBtEZJOr63Z4ck0xThhZK53HmYT2jmg=="],
|
||||
|
||||
"@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-wqzRtbyy3I595WCwwb8VBmznJTHWcTdylzVT+WBgacJDjRlT1sXaq2fRlOsHvtTRj1qG70t3PwKc6XgU0hutNg=="],
|
||||
"@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-uMzekF2miZRUwSZGvy3yYQiBAcSAs9LiXK8e3NjldxEw8xcRDWgTErxgStRoBeAD6UyzDcg/Cvwtf2guMbtR+g=="],
|
||||
|
||||
"@unocss/inspector": ["@unocss/inspector@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-rrXPlSeRfYajEL65FL1Ok9Hfhjy9zvuZZwqXh9P0qCJlou2r2IqDFO/Gf9j5yO89tnKIfJ8ff6jEyqUmzbKSMQ=="],
|
||||
"@unocss/inspector": ["@unocss/inspector@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-CpXIsqHwxCXJtUjUz6S29diHCIA+EJ1u5WML/6m2YPI4ObgWAVKrExy09inSg2icS52lFkWWdWQSeqc9kl5W6Q=="],
|
||||
|
||||
"@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-fx+pKMZ0WgT+dfinVaLkNXlx6oZFwtMbZj5O/1SQia0UcfhnyS+G35HYpbgoc9GEAl3DclxxotzZjveZm++9fA=="],
|
||||
"@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-3H12UI1rBt60PQy+S4IEeFYWu1/WQFuc2yhJ5mu/RCvX5/qwlIGanBpuh+xzTPXU1fWBlZN68yyO9uWOQgTqZQ=="],
|
||||
|
||||
"@unocss/preset-icons": ["@unocss/preset-icons@66.6.5", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-03ppAcTWD77w1WZhORT8c9beTHBtWu3cx+c4qfShOfY6LQmZgx5i7DhCij5Wcj/U1zYA4Vrh13CDEmpsdZO3Cw=="],
|
||||
"@unocss/preset-icons": ["@unocss/preset-icons@66.6.6", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.6", "ofetch": "^1.5.1" } }, "sha512-HfIEEqf3jyKexOB2Sux556n0NkPoUftb2H4+Cf7prJvKHopMkZ/OUkXjwvUlxt1e5UpAEaIa0A2Ir7+ApxXoGA=="],
|
||||
|
||||
"@unocss/preset-mini": ["@unocss/preset-mini@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Ber3k2jlE8JP0y507hw/lvdDvcxfY0t4zaGA7hVZdEqlH6Eus/TqIVZ9tdMH4u0VDWYeAs98YV+auUJmMqGXpg=="],
|
||||
"@unocss/preset-mini": ["@unocss/preset-mini@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/extractor-arbitrary-variants": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-k+/95PKMPOK57cJcSmz34VkIFem8BlujRRx6/L0Yusw7vLJMh98k0rPhC5s+NomZ/d9ZPgbNylskLhItJlak3w=="],
|
||||
|
||||
"@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-YYk/eg1OWX4Nx7rK1YZLMHXXntzNRDHp6BIInJteQmlXw0sFgrtdMKj7fnxrORsBDHwxWMp4sWEucPvfCtTlVQ=="],
|
||||
"@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-KgBXYPYS0g4TVC3NLiIB78YIqUlvDLanz1EHIDo34rOTUfMgY8Uf5VuDJAzMu4Sc0LiwwBJbk6nIG9/Zm7ufWg=="],
|
||||
|
||||
"@unocss/preset-typography": ["@unocss/preset-typography@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-Cb63tdC0P2rgj/4t4DrSCl6RHebNpjUp9FQArg0KCnFnW75nWtKlsKpHuEXpi7KwrgOIx+rjlkwC1bDcsdNLHw=="],
|
||||
"@unocss/preset-typography": ["@unocss/preset-typography@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-SM1km5nqt15z4sTabfOobSC633I5Ol5nnme6JFTra4wiyCUNs+Cg31nJ6jnopWDUT4SEAXqfUH7jKSSoCnI6ZA=="],
|
||||
|
||||
"@unocss/preset-uno": ["@unocss/preset-uno@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-feZfGyzt3dH4h6yP2kjsx5MuoI1gU7vY/VL5O+ObosaB7HzzOFCsu2WzlvWn/FTRBi+scvdq436hsfflVyHYfQ=="],
|
||||
"@unocss/preset-uno": ["@unocss/preset-uno@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6" } }, "sha512-40PcBDtlhW7QP7e/WOxC684IhN5T1dXvj1dgx9ZzK+8lEDGjcX7bN2noW4aSenzSrHymeSsMrL/0ltL4ED/5Zw=="],
|
||||
|
||||
"@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "ofetch": "^1.5.1" } }, "sha512-u5jEHYTMeseykqinXd2VY2n7q9yFQlZotREpfSAft8ENNJdV7Yg/6It3lL68zT/k1AV/A8gk94KEuDh0fnoSxQ=="],
|
||||
"@unocss/preset-web-fonts": ["@unocss/preset-web-fonts@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "ofetch": "^1.5.1" } }, "sha512-5ikwgrJB8VPzKd0bqgGNgYUGix90KFnVtKJPjWTP5qsv3+ZtZnea1rRbAFl8i2t52hg35msNBsQo+40IC3xB6A=="],
|
||||
|
||||
"@unocss/preset-wind": ["@unocss/preset-wind@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-wind3": "66.6.5" } }, "sha512-GLu7LzVF0LHqdZoHFZ8dbsCv8TD5ZH/r10CQbrL5qwmp4a/uyfDEmsre4Nsqim7JktRyXn3HK2XQmTB8AmXpgQ=="],
|
||||
"@unocss/preset-wind": ["@unocss/preset-wind@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-wind3": "66.6.6" } }, "sha512-TMy3lZ35FP/4QqDHOLWZmV+RoOGWUDqnDEOTjOKI1CQARGta0ppUmq+IZMuI1ZJLuOa4OZ9V6SfnwMXwRLgXmw=="],
|
||||
|
||||
"@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-0ccQoJmHq4tTnn5C0UKhP598B/gG65AjqlfgfRpwt059yAWYqizGy6MRUGdLklyEK4H06E6qbMBqIjla2rOexQ=="],
|
||||
"@unocss/preset-wind3": ["@unocss/preset-wind3@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/preset-mini": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-rk6gPPIQ7z2DVucOqp7XZ4vGpKAuzBV1vtUDvDh5WscxzO/QlqaeTfTALk5YgGpmLaF4+ns6FrTgLjV+wHgHuQ=="],
|
||||
|
||||
"@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/extractor-arbitrary-variants": "66.6.5", "@unocss/rule-utils": "66.6.5" } }, "sha512-JT57CU60PY3/PHBvxY+UG53I9K+awin/TodZTn4lqQNnF2v6fjkeBKiys9cxeoP4wbHuQWorrW4GqRLNDWIMcw=="],
|
||||
"@unocss/preset-wind4": ["@unocss/preset-wind4@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/extractor-arbitrary-variants": "66.6.6", "@unocss/rule-utils": "66.6.6" } }, "sha512-caTDM9rZSlp4tyPWWAnwMvQr2PXq53LsEYwd3N8zj0ou2hcsqptJvF+mFvyhvGF66x26wWJr/FwuUEhh7qycaw=="],
|
||||
|
||||
"@unocss/rule-utils": ["@unocss/rule-utils@66.6.5", "", { "dependencies": { "@unocss/core": "^66.6.5", "magic-string": "^0.30.21" } }, "sha512-eDGXoMebb5aeEAFa2y4gnGLC+CHZPx93JYCt6uvEyf9xOoetwDcZaYC8brWdjaSKn+WVgsfxiZreC7F0rJywOQ=="],
|
||||
"@unocss/rule-utils": ["@unocss/rule-utils@66.6.6", "", { "dependencies": { "@unocss/core": "^66.6.6", "magic-string": "^0.30.21" } }, "sha512-krWtQKGshOaqQMuxeGq1NOA8NL35VdpYlmQEWOe39BY6TACT51bgQFu40MRfsAIMZZtoGS2YYTrnHojgR92omw=="],
|
||||
|
||||
"@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "oxc-parser": "^0.115.0", "oxc-walker": "^0.7.0" } }, "sha512-/dVaRR7V/2Alskb2rUPmP/lhyb/YCxYyYNxp30kxxW0ew6mZWXQRzsxOJJVmGp23Uw7HxUW63t8zXzUdoI0b+g=="],
|
||||
"@unocss/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "oxc-parser": "^0.115.0", "oxc-walker": "^0.7.0" } }, "sha512-NnDchmN2EeFLy4lfVqDgNe9j1+w2RLL2L9zKECXs5g6rDVfeeEK6FNgxSq3XnPcKltjNCy1pF4MaDOROG7r8yA=="],
|
||||
|
||||
"@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-U/ukk5lyZOFNyz9hVzZBkxciayjgimyfPuQBa5PHSC4W3nDmnFd1zgXzUVaM6KduPmiTExzpJSDgELb2OTbpqg=="],
|
||||
"@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-KKssJxU8fZ9x84yznIirbtta2sB0LN/3lm0bp+Wl1298HITaNiVeG2n26iStQ3N7r240xRN2RarxncSVCMFwWw=="],
|
||||
|
||||
"@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5", "@unocss/rule-utils": "66.6.5", "css-tree": "^3.1.0" } }, "sha512-QgofDdDedNK6dQ246+RXhM6gTzRz7NuetQQ8UnNgArm4PBHngVrrkjCzG1ByDTtEtoE8WR70UMR4Vf5dXTcHPw=="],
|
||||
"@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6", "@unocss/rule-utils": "66.6.6", "css-tree": "^3.1.0" } }, "sha512-CReFTcBfMtKkRvzIqxL20VptWt5C1Om27dwoKzyVFBXv0jzViWysbu0y0AQg3bsgD4cFqndFyAGyeL84j0nbKg=="],
|
||||
|
||||
"@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-k6vQgn/P7ObHBRYw6o1+xwdQIfwc6b9O5TFFe87UmBB6hJ2zaHWRVuPB6oky7F9Gz8bPfXC3WJuv7UyIwRmBQQ=="],
|
||||
"@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.6", "", { "dependencies": { "@unocss/core": "66.6.6" } }, "sha512-j4L/0Tw6AdMVB2dDnuBlDbevyL1/0CAk88a77VF/VjgEIBwB9VXsCCUsxz+2Dohcl7N2GMm7+kpaWA6qt2PSaA=="],
|
||||
|
||||
"@unocss/vite": ["@unocss/vite@66.6.5", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.4", "@unocss/core": "66.6.5", "@unocss/inspector": "66.6.5", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-J/QZa6h94ordZlZytIKQkuYa+G2GiWiS3y9O1uoHAAN2tzFSkgCXNUif7lHu1h4eCrgC0AOHJSYWg1LIASNDkg=="],
|
||||
"@unocss/vite": ["@unocss/vite@66.6.6", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.6", "@unocss/core": "66.6.6", "@unocss/inspector": "66.6.6", "chokidar": "^5.0.0", "magic-string": "^0.30.21", "pathe": "^2.0.3", "tinyglobby": "^0.2.15", "unplugin-utils": "^0.3.1" }, "peerDependencies": { "vite": "^2.9.0 || ^3.0.0-0 || ^4.0.0 || ^5.0.0-0 || ^6.0.0-0 || ^7.0.0-0 || ^8.0.0-0" } }, "sha512-DgG7KcUUMtoDhPOlFf2l4dR+66xZ23SdZvTYpikk5nZfLCzZd62vedutD7x0bTR6VpK2YRq39B+F+Z6TktNY/w=="],
|
||||
|
||||
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@6.0.4", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.2" }, "peerDependencies": { "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0", "vue": "^3.2.25" } }, "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ=="],
|
||||
|
||||
@@ -402,13 +371,13 @@
|
||||
|
||||
"@vue/babel-plugin-resolve-type": ["@vue/babel-plugin-resolve-type@2.0.1", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/helper-module-imports": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1", "@babel/parser": "^7.28.4", "@vue/compiler-sfc": "^3.5.22" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ybwgIuRGRRBhOU37GImDoWQoz+TlSqap65qVI6iwg/J7FfLTLmMf97TS7xQH9I7Qtr/gp161kYVdhr1ZMraSYQ=="],
|
||||
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
"@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="],
|
||||
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
"@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="],
|
||||
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
"@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="],
|
||||
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
"@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="],
|
||||
|
||||
"@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="],
|
||||
|
||||
@@ -416,15 +385,15 @@
|
||||
|
||||
"@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="],
|
||||
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.29", "", { "dependencies": { "@vue/shared": "3.5.29" } }, "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA=="],
|
||||
"@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="],
|
||||
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg=="],
|
||||
"@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="],
|
||||
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.29", "", { "dependencies": { "@vue/reactivity": "3.5.29", "@vue/runtime-core": "3.5.29", "@vue/shared": "3.5.29", "csstype": "^3.2.3" } }, "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg=="],
|
||||
"@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="],
|
||||
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.29", "", { "dependencies": { "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "vue": "3.5.29" } }, "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g=="],
|
||||
"@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="],
|
||||
|
||||
"@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
"@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="],
|
||||
|
||||
"@vueuse/core": ["@vueuse/core@14.2.1", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "14.2.1", "@vueuse/shared": "14.2.1" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-3vwDzV+GDUNpdegRY6kzpLm4Igptq+GA0QkJ3W61Iv27YWwW/ufSlOfgQIpN6FZRMG0mkaz4gglJRtq5SeJyIQ=="],
|
||||
|
||||
@@ -438,6 +407,10 @@
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
|
||||
|
||||
"ast-walker-scope": ["ast-walker-scope@0.8.3", "", { "dependencies": { "@babel/parser": "^7.28.4", "ast-kit": "^2.1.3" } }, "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg=="],
|
||||
@@ -448,10 +421,10 @@
|
||||
|
||||
"birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="],
|
||||
|
||||
"blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"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=="],
|
||||
@@ -460,8 +433,14 @@
|
||||
|
||||
"chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
|
||||
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||
|
||||
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||
|
||||
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
|
||||
|
||||
"confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="],
|
||||
@@ -470,8 +449,6 @@
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],
|
||||
|
||||
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="],
|
||||
|
||||
"cross-fetch": ["cross-fetch@4.0.0", "", { "dependencies": { "node-fetch": "^2.6.12" } }, "sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g=="],
|
||||
@@ -492,9 +469,9 @@
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.302", "", {}, "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg=="],
|
||||
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="],
|
||||
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
||||
|
||||
"esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
|
||||
|
||||
@@ -502,7 +479,7 @@
|
||||
|
||||
"escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
|
||||
|
||||
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
|
||||
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
|
||||
|
||||
@@ -516,18 +493,22 @@
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
|
||||
|
||||
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
|
||||
|
||||
"hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
|
||||
"hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
|
||||
|
||||
"hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="],
|
||||
|
||||
"i18next": ["i18next@25.8.14", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA=="],
|
||||
"i18next": ["i18next@25.8.18", "", { "dependencies": { "@babel/runtime": "^7.28.6" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-lzY5X83BiL5AP77+9DydbrqkQHFN9hUzWGjqjLpPcp5ZOzuu1aSoKaU3xbBLSjWx9dAzW431y+d+aogxOZaKRA=="],
|
||||
|
||||
"i18next-http-backend": ["i18next-http-backend@3.0.2", "", { "dependencies": { "cross-fetch": "4.0.0" } }, "sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g=="],
|
||||
|
||||
"i18next-vue": ["i18next-vue@5.4.0", "", { "peerDependencies": { "i18next": ">=23", "vue": "^3.4.38" } }, "sha512-GDj0Xvmis5Xgcvo9gMBJMgJCtewYMLZP6gAEPDDGCMjA+QeB4uS4qUf1MK79mkz/FukhaJdC+nlj0y1qk6NO2Q=="],
|
||||
|
||||
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||
|
||||
"is-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="],
|
||||
|
||||
"is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
|
||||
@@ -540,8 +521,6 @@
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
|
||||
@@ -568,6 +547,10 @@
|
||||
|
||||
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
|
||||
|
||||
"lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
"magic-regexp": ["magic-regexp@0.10.0", "", { "dependencies": { "estree-walker": "^3.0.3", "magic-string": "^0.30.12", "mlly": "^1.7.2", "regexp-tree": "^0.1.27", "type-level-regexp": "~0.1.17", "ufo": "^1.5.4", "unplugin": "^2.0.0" } }, "sha512-Uly1Bu4lO1hwHUW0CQeSWuRtzCMNO00CmXtS8N6fyvB3B979GOEEeAkiTUDsmbYLAbvpUS/Kt5c4ibosAzVyVg=="],
|
||||
@@ -582,8 +565,6 @@
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="],
|
||||
@@ -612,8 +593,6 @@
|
||||
|
||||
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
@@ -628,12 +607,16 @@
|
||||
|
||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="],
|
||||
|
||||
"quansync": ["quansync@0.2.11", "", {}, "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="],
|
||||
|
||||
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"regexp-tree": ["regexp-tree@0.1.27", "", { "bin": { "regexp-tree": "bin/regexp-tree" } }, "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA=="],
|
||||
|
||||
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
|
||||
|
||||
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.6", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.6" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-x64": "1.0.0-rc.6", "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw=="],
|
||||
@@ -642,8 +625,6 @@
|
||||
|
||||
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="],
|
||||
|
||||
"sirv": ["sirv@3.0.2", "", { "dependencies": { "@polka/url": "^1.0.0-next.24", "mrmime": "^2.0.0", "totalist": "^3.0.0" } }, "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -652,12 +633,14 @@
|
||||
|
||||
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
|
||||
|
||||
"tailwind-merge": ["tailwind-merge@3.5.0", "", {}, "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
@@ -670,6 +653,8 @@
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="],
|
||||
|
||||
"type-level-regexp": ["type-level-regexp@0.1.17", "", {}, "sha512-wTk4DH3cxwk196uGLK/E9pE45aLfeKJacKmcEgEOA/q5dnPGNxXt0cfYdFxb57L+sEpf1oJH4Dnx/pnRcku9jg=="],
|
||||
|
||||
"ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
|
||||
@@ -678,17 +663,13 @@
|
||||
|
||||
"unconfig-core": ["unconfig-core@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "quansync": "^1.0.0" } }, "sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w=="],
|
||||
|
||||
"undici": ["undici@7.18.2", "", {}, "sha512-y+8YjDFzWdQlSE9N5nzKMT3g4a5UBX1HKowfdXh0uvAnTaqqwqB92Jt4UXBAeKekDs5IaDKyJFR4X1gYVCgXcw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
|
||||
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
|
||||
|
||||
"unhead": ["unhead@2.1.10", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g=="],
|
||||
"unhead": ["unhead@2.1.12", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-iTHdWD9ztTunOErtfUFk6Wr11BxvzumcYJ0CzaSCBUOEtg+DUZ9+gnE99i8QkLFT2q1rZD48BYYGXpOZVDLYkA=="],
|
||||
|
||||
"unimport": ["unimport@5.6.0", "", { "dependencies": { "acorn": "^8.15.0", "escape-string-regexp": "^5.0.0", "estree-walker": "^3.0.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "pkg-types": "^2.3.0", "scule": "^1.3.0", "strip-literal": "^3.1.0", "tinyglobby": "^0.2.15", "unplugin": "^2.3.11", "unplugin-utils": "^0.3.1" } }, "sha512-8rqAmtJV8o60x46kBAJKtHpJDJWkA2xcBqWKPI14MgUb05o1pnpnCnXSxedUXyeq7p8fR5g3pTo2BaswZ9lD9A=="],
|
||||
|
||||
"unocss": ["unocss@66.6.5", "", { "dependencies": { "@unocss/cli": "66.6.5", "@unocss/core": "66.6.5", "@unocss/preset-attributify": "66.6.5", "@unocss/preset-icons": "66.6.5", "@unocss/preset-mini": "66.6.5", "@unocss/preset-tagify": "66.6.5", "@unocss/preset-typography": "66.6.5", "@unocss/preset-uno": "66.6.5", "@unocss/preset-web-fonts": "66.6.5", "@unocss/preset-wind": "66.6.5", "@unocss/preset-wind3": "66.6.5", "@unocss/preset-wind4": "66.6.5", "@unocss/transformer-attributify-jsx": "66.6.5", "@unocss/transformer-compile-class": "66.6.5", "@unocss/transformer-directives": "66.6.5", "@unocss/transformer-variant-group": "66.6.5", "@unocss/vite": "66.6.5" }, "peerDependencies": { "@unocss/astro": "66.6.5", "@unocss/postcss": "66.6.5", "@unocss/webpack": "66.6.5" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-WlpPlV7yAzEPREcwaKeacP+1jOm6ImhyKJRkK18tIW2b2BRZZDKln7X8P+NzJtAr0kziNY/ttUKZNZRnSmzP1A=="],
|
||||
"unocss": ["unocss@66.6.6", "", { "dependencies": { "@unocss/cli": "66.6.6", "@unocss/core": "66.6.6", "@unocss/preset-attributify": "66.6.6", "@unocss/preset-icons": "66.6.6", "@unocss/preset-mini": "66.6.6", "@unocss/preset-tagify": "66.6.6", "@unocss/preset-typography": "66.6.6", "@unocss/preset-uno": "66.6.6", "@unocss/preset-web-fonts": "66.6.6", "@unocss/preset-wind": "66.6.6", "@unocss/preset-wind3": "66.6.6", "@unocss/preset-wind4": "66.6.6", "@unocss/transformer-attributify-jsx": "66.6.6", "@unocss/transformer-compile-class": "66.6.6", "@unocss/transformer-directives": "66.6.6", "@unocss/transformer-variant-group": "66.6.6", "@unocss/vite": "66.6.6" }, "peerDependencies": { "@unocss/astro": "66.6.6", "@unocss/postcss": "66.6.6", "@unocss/webpack": "66.6.6" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-PRKK945e2oZKHV664MA5Z9CDHbvY/V79IvTOUWKZ514jpl3UsJU3sS+skgxmKJSmwrWvXE5OVcmPthJrD/7vxg=="],
|
||||
|
||||
"unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="],
|
||||
|
||||
@@ -706,7 +687,7 @@
|
||||
|
||||
"vite-ssr-components": ["vite-ssr-components@0.5.2", "", { "dependencies": { "@babel/parser": "^7.27.2", "@babel/traverse": "^7.27.1", "picomatch": "^4.0.2" } }, "sha512-1a8YThRwyyu1gGjc1Ral9Q4uS+n0D4GydhbkVd9c1SA1YNgXyrOizttped87C1ItEznQzhiCyQjaOcYnXa0zMA=="],
|
||||
|
||||
"vue": ["vue@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/compiler-sfc": "3.5.29", "@vue/runtime-dom": "3.5.29", "@vue/server-renderer": "3.5.29", "@vue/shared": "3.5.29" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA=="],
|
||||
"vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="],
|
||||
|
||||
"vue-router": ["vue-router@5.0.3", "", { "dependencies": { "@babel/generator": "^7.28.6", "@vue-macros/common": "^3.1.1", "@vue/devtools-api": "^8.0.6", "ast-walker-scope": "^0.8.3", "chokidar": "^5.0.0", "json5": "^2.2.3", "local-pkg": "^1.1.2", "magic-string": "^0.30.21", "mlly": "^1.8.0", "muggle-string": "^0.4.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "scule": "^1.3.0", "tinyglobby": "^0.2.15", "unplugin": "^3.0.0", "unplugin-utils": "^0.3.1", "yaml": "^2.8.2" }, "peerDependencies": { "@pinia/colada": ">=0.21.2", "@vue/compiler-sfc": "^3.5.17", "pinia": "^3.0.4", "vue": "^3.5.0" }, "optionalPeers": ["@pinia/colada", "@vue/compiler-sfc", "pinia"] }, "sha512-nG1c7aAFac7NYj8Hluo68WyWfc41xkEjaR0ViLHCa3oDvTQ/nIuLJlXJX1NUPw/DXzx/8+OKMng045HHQKQKWw=="],
|
||||
|
||||
@@ -716,54 +697,70 @@
|
||||
|
||||
"whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
|
||||
|
||||
"workerd": ["workerd@1.20260301.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260301.1", "@cloudflare/workerd-darwin-arm64": "1.20260301.1", "@cloudflare/workerd-linux-64": "1.20260301.1", "@cloudflare/workerd-linux-arm64": "1.20260301.1", "@cloudflare/workerd-windows-64": "1.20260301.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw=="],
|
||||
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
|
||||
|
||||
"wrangler": ["wrangler@4.70.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.14.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260301.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260226.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-PNDZ9o4e+B5x+1bUbz62Hmwz6G9lw+I9pnYe/AguLddJFjfIyt2cmFOUOb3eOZSoXsrhcEPUg2YidYIbVwUkfw=="],
|
||||
|
||||
"ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="],
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
|
||||
"yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
|
||||
|
||||
"@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="],
|
||||
|
||||
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"@unocss/config/@unocss/core": ["@unocss/core@66.6.4", "", {}, "sha512-Fii3lhVJVFrKUz6hMGAkq3sXBfNnXB2G8bldNHuBHJpDAoP1F0oO/SU/oSqSjCYvtcD5RtOn8qwzcHuuN3B/mg=="],
|
||||
|
||||
"@vitejs/plugin-vue-jsx/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
|
||||
|
||||
"@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"@vue-macros/common/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
|
||||
"@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
|
||||
"@vue/babel-plugin-jsx/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.29", "@vue/compiler-dom": "3.5.29", "@vue/compiler-ssr": "3.5.29", "@vue/shared": "3.5.29", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.6", "source-map-js": "^1.2.1" } }, "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA=="],
|
||||
|
||||
"@vue/compiler-sfc/postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"@vue/devtools-kit/hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"@vue/devtools-kit/perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="],
|
||||
|
||||
"magic-regexp/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.6", "", {}, "sha512-Y0+JT8Mi1mmW08K6HieG315XNRu4L0rkfCpA364HtytjgiqYnMYRdFPcxRl+BQQqNXzecL2S9nii+RUpO93XIA=="],
|
||||
|
||||
"sharp/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
|
||||
"strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
|
||||
|
||||
"unconfig/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"unconfig-core/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
|
||||
|
||||
"unimport/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
|
||||
|
||||
"vue-router/@vue/devtools-api": ["@vue/devtools-api@8.0.6", "", { "dependencies": { "@vue/devtools-kit": "^8.0.6" } }, "sha512-+lGBI+WTvJmnU2FZqHhEB8J1DXcvNlDeEalz77iYgOdY1jTj1ipSBaKj3sRhYcy+kqA8v/BSuvOz1XJucfQmUA=="],
|
||||
|
||||
"vue-router/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
|
||||
"@vue-macros/common/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-core": ["@vue/compiler-core@3.5.29", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.29", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-dom": ["@vue/compiler-dom@3.5.29", "", { "dependencies": { "@vue/compiler-core": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.29", "", { "dependencies": { "@vue/compiler-dom": "3.5.29", "@vue/shared": "3.5.29" } }, "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw=="],
|
||||
|
||||
"@vue/babel-plugin-resolve-type/@vue/compiler-sfc/@vue/shared": ["@vue/shared@3.5.29", "", {}, "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg=="],
|
||||
|
||||
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="],
|
||||
|
||||
"vue-router/@vue/devtools-api/@vue/devtools-kit": ["@vue/devtools-kit@8.0.6", "", { "dependencies": { "@vue/devtools-shared": "^8.0.6", "birpc": "^2.6.1", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^2.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-9zXZPTJW72OteDXeSa5RVML3zWDCRcO5t77aJqSs228mdopYj5AiTpihozbsfFJ0IodfNs7pSgOGO3qfCuxDtw=="],
|
||||
|
||||
38
components.d.ts
vendored
38
components.d.ts
vendored
@@ -17,16 +17,18 @@ declare module 'vue' {
|
||||
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
|
||||
AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
|
||||
AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
|
||||
AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
|
||||
AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
|
||||
AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
|
||||
AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
|
||||
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
|
||||
BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
@@ -35,6 +37,7 @@ declare module 'vue' {
|
||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
@@ -58,6 +61,7 @@ declare module 'vue' {
|
||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
@@ -78,6 +82,7 @@ declare module 'vue' {
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
@@ -97,16 +102,18 @@ declare global {
|
||||
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
const AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
const AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
const AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
|
||||
const AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
|
||||
const AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
|
||||
const AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
|
||||
const AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
|
||||
const AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
|
||||
const AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
|
||||
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
|
||||
const BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
@@ -115,6 +122,7 @@ declare global {
|
||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
const copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
@@ -138,6 +146,7 @@ declare global {
|
||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
@@ -158,6 +167,7 @@ declare global {
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
|
||||
BIN
golang.tar.gz
BIN
golang.tar.gz
Binary file not shown.
36
package.json
36
package.json
@@ -2,42 +2,46 @@
|
||||
"name": "holistream",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun vite",
|
||||
"build": "bun vite build",
|
||||
"preview": "bun vite preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"tail": "wrangler tail"
|
||||
"dev": "bunx --bun vite",
|
||||
"build": "bunx --bun vite build",
|
||||
"preview": "bunx --bun vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@hattip/adapter-node": "^0.0.49",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@hono/node-server": "^1.19.11",
|
||||
"@pinia/colada": "^0.21.7",
|
||||
"@unhead/vue": "^2.1.10",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^1.0.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.12.5",
|
||||
"i18next": "^25.8.14",
|
||||
"hono": "^4.12.7",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"vue": "^3.5.29",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"vue": "^3.5.30",
|
||||
"vue-router": "^5.0.3",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.26.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/bun": "^1.3.10",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.5",
|
||||
"estree-walker": "2.0.2",
|
||||
"unocss": "^66.6.6",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^8.0.0-beta.16",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.70.0"
|
||||
"vite-ssr-components": "^0.5.2"
|
||||
}
|
||||
}
|
||||
|
||||
46
proto/v1/common.proto
Normal file
46
proto/v1/common.proto
Normal file
@@ -0,0 +1,46 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package stream.common.v1;
|
||||
|
||||
option go_package = "stream/proto/gen/go/common/v1;commonv1";
|
||||
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
message RequestContext {
|
||||
string user_id = 1;
|
||||
string email = 2;
|
||||
string role = 3;
|
||||
string request_id = 4;
|
||||
string source = 5;
|
||||
}
|
||||
|
||||
message PaginationRequest {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
}
|
||||
|
||||
message PaginationResponse {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
int64 total = 3;
|
||||
}
|
||||
|
||||
message Money {
|
||||
double amount = 1;
|
||||
string currency = 2;
|
||||
}
|
||||
|
||||
message Empty {}
|
||||
|
||||
message IdRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteResponse {
|
||||
string message = 1;
|
||||
}
|
||||
|
||||
message TimestampRange {
|
||||
google.protobuf.Timestamp from = 1;
|
||||
google.protobuf.Timestamp to = 2;
|
||||
}
|
||||
134
proto/v1/user.proto
Normal file
134
proto/v1/user.proto
Normal file
@@ -0,0 +1,134 @@
|
||||
syntax = "proto3";
|
||||
|
||||
package stream.User.v1;
|
||||
|
||||
option go_package = "stream/proto/gen/go/User/v1;Userv1";
|
||||
import "google/protobuf/empty.proto";
|
||||
import "google/protobuf/timestamp.proto";
|
||||
|
||||
service UserService {
|
||||
// User CRUD
|
||||
rpc GetUser(GetUserRequest) returns (GetUserResponse);
|
||||
rpc GetUserByEmail(GetUserByEmailRequest) returns (GetUserResponse);
|
||||
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
|
||||
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
|
||||
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
|
||||
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
|
||||
rpc UpdateUserPassword(UpdateUserPasswordRequest) returns (google.protobuf.Empty);
|
||||
|
||||
// Preferences
|
||||
rpc GetPreferences(GetPreferencesRequest) returns (GetPreferencesResponse);
|
||||
rpc UpsertPreferences(UpsertPreferencesRequest) returns (UpsertPreferencesResponse);
|
||||
}
|
||||
|
||||
// ─── User Messages ───────────────────────────────────────────────────────────
|
||||
message UpdateUserPasswordRequest {
|
||||
string id = 1;
|
||||
string new_password = 2;
|
||||
}
|
||||
message GetUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message GetUserByEmailRequest {
|
||||
string email = 1;
|
||||
}
|
||||
|
||||
message GetUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message ListUsersRequest {
|
||||
int32 page = 1;
|
||||
int32 page_size = 2;
|
||||
string role = 3; // optional filter
|
||||
}
|
||||
|
||||
message ListUsersResponse {
|
||||
repeated User users = 1;
|
||||
int32 total = 2;
|
||||
int32 page = 3;
|
||||
int32 page_size = 4;
|
||||
}
|
||||
|
||||
message CreateUserRequest {
|
||||
string email = 1;
|
||||
optional string username = 2;
|
||||
optional string password = 3;
|
||||
}
|
||||
|
||||
message CreateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message UpdateUserRequest {
|
||||
string id = 1;
|
||||
optional string username = 2;
|
||||
optional string avatar = 3;
|
||||
optional string role = 4;
|
||||
optional string plan_id = 5;
|
||||
}
|
||||
|
||||
message UpdateUserResponse {
|
||||
User user = 1;
|
||||
}
|
||||
|
||||
message DeleteUserRequest {
|
||||
string id = 1;
|
||||
}
|
||||
|
||||
message DeleteUserResponse {
|
||||
bool success = 1;
|
||||
}
|
||||
|
||||
// ─── Preferences Messages ────────────────────────────────────────────────────
|
||||
|
||||
message GetPreferencesRequest {
|
||||
string user_id = 1;
|
||||
}
|
||||
|
||||
message GetPreferencesResponse {
|
||||
Preferences preferences = 1;
|
||||
}
|
||||
|
||||
message UpsertPreferencesRequest {
|
||||
Preferences preferences = 1;
|
||||
}
|
||||
|
||||
message UpsertPreferencesResponse {
|
||||
Preferences preferences = 1;
|
||||
}
|
||||
|
||||
// ─── Core Models ─────────────────────────────────────────────────────────────
|
||||
|
||||
message User {
|
||||
string id = 1;
|
||||
string email = 2;
|
||||
string password = 3;
|
||||
optional string username = 4;
|
||||
optional string avatar = 5;
|
||||
optional string role = 6;
|
||||
optional string google_id = 7;
|
||||
int64 storage_used = 8;
|
||||
optional string plan_id = 9;
|
||||
optional google.protobuf.Timestamp created_at = 10;
|
||||
google.protobuf.Timestamp updated_at = 11;
|
||||
}
|
||||
|
||||
message Preferences {
|
||||
string user_id = 1;
|
||||
optional string language = 2;
|
||||
optional string locale = 3;
|
||||
optional bool email_notifications = 4;
|
||||
optional bool push_notifications = 5;
|
||||
optional bool marketing_notifications = 6;
|
||||
optional bool telegram_notifications = 7;
|
||||
optional bool autoplay = 8;
|
||||
optional bool loop = 9;
|
||||
optional bool muted = 10;
|
||||
optional bool show_controls = 11;
|
||||
optional bool pip = 12;
|
||||
optional bool airplay = 13;
|
||||
optional bool chromecast = 14;
|
||||
optional bool encrytion_m3u8 = 15;
|
||||
}
|
||||
@@ -112,7 +112,8 @@
|
||||
"security": "Security",
|
||||
"billing": "Billing & Plans",
|
||||
"notifications": "Notifications",
|
||||
"player": "Player",
|
||||
"playerGroup": "Player",
|
||||
"playerConfigs": "Player Configs",
|
||||
"domains": "Allowed Domains",
|
||||
"ads": "Ads & VAST",
|
||||
"danger": "Danger Zone"
|
||||
@@ -128,9 +129,9 @@
|
||||
"title": "Notifications",
|
||||
"subtitle": "Choose how you want to receive notifications and updates."
|
||||
},
|
||||
"player": {
|
||||
"title": "Player Settings",
|
||||
"subtitle": "Configure default video player behavior and features."
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"subtitle": "Manage your account preferences and notification channels."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Billing & Plans",
|
||||
@@ -144,6 +145,10 @@
|
||||
"title": "Ads & VAST",
|
||||
"subtitle": "Create and manage VAST ad templates for your videos."
|
||||
},
|
||||
"playerConfigs": {
|
||||
"title": "Player Configs",
|
||||
"subtitle": "Create and manage player configurations for your videos."
|
||||
},
|
||||
"danger": {
|
||||
"title": "Danger Zone",
|
||||
"subtitle": "Irreversible and destructive actions. Be careful!"
|
||||
@@ -293,6 +298,126 @@
|
||||
"failedDetail": "Failed to load or update domains."
|
||||
}
|
||||
},
|
||||
"playerConfigs": {
|
||||
"createConfig": "Create Config",
|
||||
"infoBanner": "Player configs let you customize playback behavior such as autoplay, loop, controls, and casting features.",
|
||||
"freePlanTitle": "Free plan limit",
|
||||
"freePlanMessage": "Free accounts can create and manage 1 player config. After you create one, create is disabled until you delete it.",
|
||||
"reconciliationTitle": "Too many configs for free plan",
|
||||
"reconciliationMessage": "Your account still has more than 1 player config from a previous paid plan. Delete extra configs until only 1 remains to edit, enable, or set a default again.",
|
||||
"readOnlyTitle": "Free plan limit",
|
||||
"readOnlyMessage": "Free accounts can manage 1 player config. Delete extra configs after downgrade to continue editing.",
|
||||
"defaultBadge": "Default",
|
||||
"createdOn": "Created {{date}}",
|
||||
"emptyTitle": "No player configs yet",
|
||||
"emptySubtitle": "Create your first config to customize video playback",
|
||||
"items": {
|
||||
"autoplay": {
|
||||
"title": "Autoplay",
|
||||
"description": "Automatically start videos when loaded"
|
||||
},
|
||||
"loop": {
|
||||
"title": "Loop",
|
||||
"description": "Repeat video when it ends"
|
||||
},
|
||||
"muted": {
|
||||
"title": "Muted",
|
||||
"description": "Start videos with sound muted"
|
||||
},
|
||||
"showControls": {
|
||||
"title": "Show Controls",
|
||||
"description": "Display player controls during playback"
|
||||
},
|
||||
"pip": {
|
||||
"title": "Picture in Picture",
|
||||
"description": "Enable Picture-in-Picture mode"
|
||||
},
|
||||
"airplay": {
|
||||
"title": "AirPlay",
|
||||
"description": "Allow streaming to Apple devices via AirPlay"
|
||||
},
|
||||
"chromecast": {
|
||||
"title": "Chromecast",
|
||||
"description": "Allow casting to Chromecast devices"
|
||||
},
|
||||
"encrytionM3u8": {
|
||||
"title": "HLS Encryption (m3u8)",
|
||||
"description": "Enable encryption for HLS streams."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"autoplay": "Autoplay",
|
||||
"loop": "Loop",
|
||||
"muted": "Muted",
|
||||
"controls": "Controls",
|
||||
"pip": "PiP",
|
||||
"airplay": "AirPlay",
|
||||
"chromecast": "Chromecast",
|
||||
"encrytionM3u8": "Encrypted HLS",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"state": {
|
||||
"enabled": "enabled",
|
||||
"disabled": "disabled"
|
||||
},
|
||||
"actions": {
|
||||
"default": "Default",
|
||||
"setDefault": "Set Default"
|
||||
},
|
||||
"table": {
|
||||
"name": "Name",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"dialog": {
|
||||
"editTitle": "Edit Config",
|
||||
"createTitle": "Create Player Config",
|
||||
"name": "Config Name",
|
||||
"namePlaceholder": "e.g., Mobile Player, Desktop Player",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "Brief description for this config",
|
||||
"playbackOptions": "Playback Options",
|
||||
"castingOptions": "Casting Options",
|
||||
"advancedOptions": "Advanced Options",
|
||||
"logoUrl": "Logo URL",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoUrlHint": "Optional logo image shown in the player overlay.",
|
||||
"defaultLabel": "Default Config",
|
||||
"defaultCheckbox": "Use this config as default for new videos",
|
||||
"defaultHint": "When enabled, newly created videos will automatically use this active config.",
|
||||
"defaultDisabledHint": "Please enable this config before setting it as default.",
|
||||
"update": "Update",
|
||||
"create": "Create"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMessage": "Are you sure you want to delete \"{name}\"?",
|
||||
"deleteHeader": "Delete Config",
|
||||
"deleteAccept": "Delete",
|
||||
"deleteReject": "Cancel"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequiredSummary": "Name required",
|
||||
"nameRequiredDetail": "Please enter a config name.",
|
||||
"updatedSummary": "Config updated",
|
||||
"updatedDetail": "Player config has been updated.",
|
||||
"createdSummary": "Config created",
|
||||
"createdDetail": "Player config has been created.",
|
||||
"enabledSummary": "Config enabled",
|
||||
"disabledSummary": "Config disabled",
|
||||
"defaultUpdatedSummary": "Default updated",
|
||||
"defaultUpdatedDetail": "{name} is now the default config for new videos.",
|
||||
"upgradeRequiredSummary": "Config limit reached",
|
||||
"upgradeRequiredDetail": "Free accounts can only have 1 player config.",
|
||||
"limitSummary": "Config limit reached",
|
||||
"limitDetail": "Free accounts can only have 1 player config.",
|
||||
"reconciliationSummary": "Delete extra configs",
|
||||
"reconciliationDetail": "Delete extra player configs until only 1 remains to continue managing them on the free plan.",
|
||||
"toggleDetail": "{name} has been {state}.",
|
||||
"deletedSummary": "Config deleted",
|
||||
"deletedDetail": "Player config has been removed.",
|
||||
"failedSummary": "Action failed",
|
||||
"failedDetail": "Failed to load or update player configs."
|
||||
}
|
||||
},
|
||||
"adsVast": {
|
||||
"createTemplate": "Create Template",
|
||||
"infoBanner": "VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.",
|
||||
@@ -629,6 +754,13 @@
|
||||
"toast": {
|
||||
"dismissAria": "Dismiss"
|
||||
},
|
||||
"network": {
|
||||
"offline": {
|
||||
"title": "You're offline",
|
||||
"description": "Your internet connection appears to be unavailable. Check your network and we'll reconnect automatically when you're back online.",
|
||||
"action": "Try again"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"welcome": {
|
||||
"title": "Hello, {{name}}",
|
||||
@@ -638,7 +770,27 @@
|
||||
"totalVideos": "Total Videos",
|
||||
"totalViews": "Total Views",
|
||||
"storageUsed": "Storage Used",
|
||||
"trendVsLastMonth": "vs last month"
|
||||
"trendVsLastMonth": "vs last month",
|
||||
"unlimited": "Unlimited"
|
||||
},
|
||||
"admin-quickActions": {
|
||||
"title": "Admin Quick Actions",
|
||||
"manageUsers": {
|
||||
"title": "Manage Users",
|
||||
"description": "View and manage all user accounts"
|
||||
},
|
||||
"viewReports": {
|
||||
"title": "View Reports",
|
||||
"description": "Access detailed analytics and reports"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "System Settings",
|
||||
"description": "Configure system-wide settings and preferences"
|
||||
},
|
||||
"billingOverview": {
|
||||
"title": "Billing Overview",
|
||||
"description": "Monitor billing and subscription details"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Quick Actions",
|
||||
@@ -716,7 +868,7 @@
|
||||
},
|
||||
"filters": {
|
||||
"searchPlaceholder": "Search videos...",
|
||||
"rangeOfTotal": "{first}–{last} of {{total}}",
|
||||
"rangeOfTotal": "{{first}}–{{last}} of {{total}}",
|
||||
"previousPageAria": "Previous page",
|
||||
"nextPageAria": "Next page",
|
||||
"allStatus": "All Status",
|
||||
@@ -1008,7 +1160,7 @@
|
||||
"description": "Content delivered from 200+ PoPs worldwide. Automatic region selection ensures the lowest latency for every viewer."
|
||||
},
|
||||
"live": {
|
||||
"title": "Live Streaming API",
|
||||
"title": "Streaming API",
|
||||
"description": "Scale to millions of concurrent viewers with ultra-low latency. RTMP ingest and HLS playback supported natively.",
|
||||
"status": "Live Status",
|
||||
"onAir": "On Air",
|
||||
|
||||
@@ -112,7 +112,8 @@
|
||||
"security": "Bảo mật",
|
||||
"billing": "Thanh toán & Gói",
|
||||
"notifications": "Thông báo",
|
||||
"player": "Trình phát",
|
||||
"playerGroup": "Trình phát",
|
||||
"playerConfigs": "Cấu hình trình phát",
|
||||
"domains": "Tên miền được phép",
|
||||
"ads": "Quảng cáo & VAST",
|
||||
"danger": "Vùng nguy hiểm"
|
||||
@@ -128,9 +129,9 @@
|
||||
"title": "Thông báo",
|
||||
"subtitle": "Chọn cách bạn muốn nhận thông báo và cập nhật."
|
||||
},
|
||||
"player": {
|
||||
"title": "Cài đặt trình phát",
|
||||
"subtitle": "Cấu hình hành vi và tính năng mặc định của trình phát video."
|
||||
"preferences": {
|
||||
"title": "Tùy chọn",
|
||||
"subtitle": "Quản lý các tùy chọn tài khoản và kênh thông báo của bạn."
|
||||
},
|
||||
"billing": {
|
||||
"title": "Thanh toán & Gói",
|
||||
@@ -144,6 +145,10 @@
|
||||
"title": "Quảng cáo & VAST",
|
||||
"subtitle": "Tạo và quản lý mẫu quảng cáo VAST cho video."
|
||||
},
|
||||
"playerConfigs": {
|
||||
"title": "Cấu hình trình phát",
|
||||
"subtitle": "Tạo và quản lý cấu hình trình phát cho video."
|
||||
},
|
||||
"danger": {
|
||||
"title": "Vùng nguy hiểm",
|
||||
"subtitle": "Hành động không thể hoàn tác và có tính phá hủy. Hãy cẩn thận!"
|
||||
@@ -293,6 +298,126 @@
|
||||
"failedDetail": "Không thể tải hoặc cập nhật danh sách tên miền."
|
||||
}
|
||||
},
|
||||
"playerConfigs": {
|
||||
"createConfig": "Tạo cấu hình",
|
||||
"infoBanner": "Cấu hình trình phát cho phép tùy chỉnh hành vi phát video như tự động phát, lặp, hiển thị điều khiển và các tính năng casting.",
|
||||
"freePlanTitle": "Giới hạn gói free",
|
||||
"freePlanMessage": "Tài khoản free có thể tạo và quản lý 1 player config. Sau khi đã có 1 config, bạn cần xóa nó trước khi tạo config mới.",
|
||||
"reconciliationTitle": "Có quá nhiều config cho gói free",
|
||||
"reconciliationMessage": "Tài khoản của bạn vẫn còn hơn 1 player config từ gói paid trước đó. Hãy xóa bớt cho đến khi chỉ còn 1 config để có thể sửa, bật/tắt hoặc đặt mặc định trở lại.",
|
||||
"readOnlyTitle": "Giới hạn gói free",
|
||||
"readOnlyMessage": "Tài khoản free có thể quản lý 1 player config. Sau khi downgrade, hãy xóa bớt config dư để tiếp tục chỉnh sửa.",
|
||||
"defaultBadge": "Mặc định",
|
||||
"createdOn": "Tạo ngày {{date}}",
|
||||
"emptyTitle": "Chưa có cấu hình",
|
||||
"emptySubtitle": "Tạo config đầu tiên để tùy chỉnh trải nghiệm phát video",
|
||||
"items": {
|
||||
"autoplay": {
|
||||
"title": "Tự phát",
|
||||
"description": "Tự động phát video khi tải xong"
|
||||
},
|
||||
"loop": {
|
||||
"title": "Lặp lại",
|
||||
"description": "Phát lại video khi kết thúc"
|
||||
},
|
||||
"muted": {
|
||||
"title": "Tắt tiếng",
|
||||
"description": "Bắt đầu video với âm thanh tắt"
|
||||
},
|
||||
"showControls": {
|
||||
"title": "Hiển thị điều khiển",
|
||||
"description": "Hiển thị thanh điều khiển phát video"
|
||||
},
|
||||
"pip": {
|
||||
"title": "Picture in Picture",
|
||||
"description": "Bật chế độ Picture-in-Picture"
|
||||
},
|
||||
"airplay": {
|
||||
"title": "AirPlay",
|
||||
"description": "Cho phép phát tới thiết bị Apple qua AirPlay"
|
||||
},
|
||||
"chromecast": {
|
||||
"title": "Chromecast",
|
||||
"description": "Cho phép cast tới thiết bị Chromecast"
|
||||
},
|
||||
"encrytionM3u8": {
|
||||
"title": "Mã hóa HLS (m3u8)",
|
||||
"description": "Bật mã hóa cho luồng HLS."
|
||||
}
|
||||
},
|
||||
"badges": {
|
||||
"autoplay": "Tự phát",
|
||||
"loop": "Lặp",
|
||||
"muted": "Tắt tiếng",
|
||||
"controls": "Điều khiển",
|
||||
"pip": "PiP",
|
||||
"airplay": "AirPlay",
|
||||
"chromecast": "Chromecast",
|
||||
"encrytionM3u8": "HLS mã hóa",
|
||||
"logo": "Logo"
|
||||
},
|
||||
"state": {
|
||||
"enabled": "bật",
|
||||
"disabled": "tắt"
|
||||
},
|
||||
"actions": {
|
||||
"default": "Mặc định",
|
||||
"setDefault": "Đặt mặc định"
|
||||
},
|
||||
"table": {
|
||||
"name": "Tên",
|
||||
"settings": "Cài đặt"
|
||||
},
|
||||
"dialog": {
|
||||
"editTitle": "Sửa cấu hình",
|
||||
"createTitle": "Tạo cấu hình trình phát",
|
||||
"name": "Tên cấu hình",
|
||||
"namePlaceholder": "ví dụ: Mobile Player, Desktop Player",
|
||||
"description": "Mô tả",
|
||||
"descriptionPlaceholder": "Mô tả ngắn cho cấu hình này",
|
||||
"playbackOptions": "Tùy chọn phát lại",
|
||||
"castingOptions": "Tùy chọn casting",
|
||||
"advancedOptions": "Tùy chọn nâng cao",
|
||||
"logoUrl": "URL logo",
|
||||
"logoUrlPlaceholder": "https://example.com/logo.png",
|
||||
"logoUrlHint": "Logo tùy chọn hiển thị trong lớp phủ của trình phát.",
|
||||
"defaultLabel": "Cấu hình mặc định",
|
||||
"defaultCheckbox": "Dùng cấu hình này mặc định cho video mới",
|
||||
"defaultHint": "Khi bật, video mới tạo sẽ tự động dùng cấu hình đang active này.",
|
||||
"defaultDisabledHint": "Hãy bật cấu hình này trước khi đặt làm mặc định.",
|
||||
"update": "Cập nhật",
|
||||
"create": "Tạo"
|
||||
},
|
||||
"confirm": {
|
||||
"deleteMessage": "Bạn có chắc muốn xóa \"{name}\"?",
|
||||
"deleteHeader": "Xóa cấu hình",
|
||||
"deleteAccept": "Xóa",
|
||||
"deleteReject": "Hủy"
|
||||
},
|
||||
"toast": {
|
||||
"nameRequiredSummary": "Thiếu tên cấu hình",
|
||||
"nameRequiredDetail": "Vui lòng nhập tên cấu hình.",
|
||||
"updatedSummary": "Đã cập nhật cấu hình",
|
||||
"updatedDetail": "Cấu hình trình phát đã được cập nhật.",
|
||||
"createdSummary": "Đã tạo cấu hình",
|
||||
"createdDetail": "Cấu hình trình phát đã được tạo.",
|
||||
"enabledSummary": "Đã bật cấu hình",
|
||||
"disabledSummary": "Đã tắt cấu hình",
|
||||
"defaultUpdatedSummary": "Đã cập nhật mặc định",
|
||||
"defaultUpdatedDetail": "{name} hiện là cấu hình mặc định cho video mới.",
|
||||
"upgradeRequiredSummary": "Đã đạt giới hạn cấu hình",
|
||||
"upgradeRequiredDetail": "Tài khoản free chỉ có thể có 1 player config.",
|
||||
"limitSummary": "Đã đạt giới hạn cấu hình",
|
||||
"limitDetail": "Tài khoản free chỉ có thể có 1 player config.",
|
||||
"reconciliationSummary": "Hãy xóa bớt config",
|
||||
"reconciliationDetail": "Hãy xóa các player config dư cho đến khi chỉ còn 1 config để tiếp tục quản lý trên gói free.",
|
||||
"toggleDetail": "{name} đã được {state}.",
|
||||
"deletedSummary": "Đã xóa cấu hình",
|
||||
"deletedDetail": "Cấu hình trình phát đã được gỡ bỏ.",
|
||||
"failedSummary": "Thao tác thất bại",
|
||||
"failedDetail": "Không thể tải hoặc cập nhật cấu hình trình phát."
|
||||
}
|
||||
},
|
||||
"adsVast": {
|
||||
"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.",
|
||||
@@ -628,6 +753,13 @@
|
||||
"toast": {
|
||||
"dismissAria": "Đóng"
|
||||
},
|
||||
"network": {
|
||||
"offline": {
|
||||
"title": "Bạn đang ngoại tuyến",
|
||||
"description": "Có vẻ như kết nối internet đã bị ngắt. Hãy kiểm tra mạng, ứng dụng sẽ tự kết nối lại khi bạn có mạng trở lại.",
|
||||
"action": "Thử lại"
|
||||
}
|
||||
},
|
||||
"overview": {
|
||||
"welcome": {
|
||||
"title": "Xin chào, {{name}}",
|
||||
@@ -637,7 +769,23 @@
|
||||
"totalVideos": "Tổng số video",
|
||||
"totalViews": "Tổng lượt xem",
|
||||
"storageUsed": "Dung lượng đã dùng",
|
||||
"trendVsLastMonth": "so với tháng trước"
|
||||
"trendVsLastMonth": "so với tháng trước",
|
||||
"unlimited": "Không giới hạn"
|
||||
},
|
||||
"admin-quickActions": {
|
||||
"title": "Thao tác nhanh cho quản trị viên",
|
||||
"manageUsers": {
|
||||
"title": "Quản lý người dùng",
|
||||
"description": "Xem và quản lý tất cả người dùng"
|
||||
},
|
||||
"viewReports": {
|
||||
"title": "Xem báo cáo",
|
||||
"description": "Phân tích hiệu suất hệ thống và hoạt động của người dùng"
|
||||
},
|
||||
"systemSettings": {
|
||||
"title": "Cài đặt hệ thống",
|
||||
"description": "Cấu hình cài đặt và tùy chọn của hệ thống"
|
||||
}
|
||||
},
|
||||
"quickActions": {
|
||||
"title": "Thao tác nhanh",
|
||||
@@ -1007,7 +1155,7 @@
|
||||
"description": "Nội dung được phân phối từ hơn 200 PoP trên toàn thế giới. Tự động chọn vùng để có độ trễ thấp nhất cho mọi người xem."
|
||||
},
|
||||
"live": {
|
||||
"title": "Live Streaming API",
|
||||
"title": "Streaming API",
|
||||
"description": "Mở rộng tới hàng triệu người xem đồng thời với độ trễ cực thấp. Hỗ trợ RTMP ingest và HLS playback sẵn có.",
|
||||
"status": "Trạng thái trực tiếp",
|
||||
"onAir": "Đang phát",
|
||||
|
||||
7
scripts/gen-nacl-keys.ts
Normal file
7
scripts/gen-nacl-keys.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// scripts/gen-nacl-keys.ts
|
||||
import nacl from "tweetnacl";
|
||||
|
||||
const kp = nacl.box.keyPair();
|
||||
|
||||
console.log("PUBLIC_KEY_BASE64=", Buffer.from(kp.publicKey).toString("base64"));
|
||||
console.log("SECRET_KEY_BASE64=", Buffer.from(kp.secretKey).toString("base64"));
|
||||
1962
src/api/client.ts
1962
src/api/client.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,78 @@
|
||||
export const customFetch: typeof fetch = (input, init) => {
|
||||
return fetch(input, {
|
||||
...init,
|
||||
credentials: 'include',
|
||||
});
|
||||
};
|
||||
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
JSON?: Partial<JsonTransformer>;
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
const JSON: JsonTransformer = {
|
||||
parse: globalThis.JSON.parse,
|
||||
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
|
||||
...opts.JSON,
|
||||
};
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const extraHeaders = opts.headers ? await opts.headers() : {};
|
||||
const payload = JSON.stringify(data.args, (headerObj) => {
|
||||
if (headerObj) {
|
||||
Object.assign(extraHeaders, headerObj);
|
||||
}
|
||||
});
|
||||
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
|
||||
let req: Request;
|
||||
if (method === "GET") {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||
{
|
||||
headers: extraHeaders
|
||||
}
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
let res: Response;
|
||||
|
||||
res = await fetch(req);
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: { message: await res.text() },
|
||||
internal: true,
|
||||
})
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text(),
|
||||
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,125 +1,88 @@
|
||||
import { tryGetContext } from 'hono/context-storage';
|
||||
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
// export const baseAPIURL = 'https://api.pipic.fun';
|
||||
export const baseAPIURL = 'http://localhost:8080';
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
export const baseAPIURL = "https://api.pipic.fun";
|
||||
|
||||
type RequestOptions = RequestInit | { raw: Request };
|
||||
|
||||
const isRequest = (input: URL | RequestInfo): input is Request =>
|
||||
typeof Request !== 'undefined' && input instanceof Request;
|
||||
|
||||
const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } =>
|
||||
typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request;
|
||||
|
||||
const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
|
||||
if (input instanceof URL) return new URL(input.toString());
|
||||
if (isRequest(input)) return new URL(input.url);
|
||||
|
||||
const baseUrl = new URL(currentRequestUrl);
|
||||
baseUrl.pathname = '/';
|
||||
baseUrl.search = '';
|
||||
baseUrl.hash = '';
|
||||
|
||||
return new URL(input, baseUrl);
|
||||
};
|
||||
|
||||
const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
|
||||
const inputUrl = resolveInputUrl(input, currentRequestUrl);
|
||||
const apiUrl = new URL(baseAPIURL);
|
||||
|
||||
apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/';
|
||||
apiUrl.search = inputUrl.search;
|
||||
apiUrl.hash = inputUrl.hash;
|
||||
|
||||
return apiUrl;
|
||||
};
|
||||
|
||||
const getOptionHeaders = (options: RequestOptions) =>
|
||||
isRequestLikeOptions(options) ? options.raw.headers : options.headers;
|
||||
|
||||
const getOptionMethod = (options: RequestOptions) =>
|
||||
isRequestLikeOptions(options) ? options.raw.method : options.method;
|
||||
|
||||
const getOptionBody = (options: RequestOptions) =>
|
||||
isRequestLikeOptions(options) ? options.raw.body : options.body;
|
||||
|
||||
const getOptionSignal = (options: RequestOptions) =>
|
||||
isRequestLikeOptions(options) ? options.raw.signal : options.signal;
|
||||
|
||||
const getOptionCredentials = (options: RequestOptions) =>
|
||||
isRequestLikeOptions(options) ? undefined : options.credentials;
|
||||
|
||||
const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => {
|
||||
const c = tryGetContext<any>();
|
||||
const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined);
|
||||
const inputHeaders = isRequest(input) ? input.headers : undefined;
|
||||
const optionHeaders = getOptionHeaders(options);
|
||||
|
||||
new Headers(inputHeaders).forEach((value, key) => {
|
||||
mergedHeaders.set(key, value);
|
||||
});
|
||||
|
||||
new Headers(optionHeaders).forEach((value, key) => {
|
||||
mergedHeaders.set(key, value);
|
||||
});
|
||||
|
||||
mergedHeaders.delete('host');
|
||||
mergedHeaders.delete('connection');
|
||||
mergedHeaders.delete('content-length');
|
||||
mergedHeaders.delete('transfer-encoding');
|
||||
|
||||
return mergedHeaders;
|
||||
};
|
||||
|
||||
const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => {
|
||||
const method = getOptionMethod(options);
|
||||
if (method) return method;
|
||||
if (isRequest(input)) return input.method;
|
||||
return 'GET';
|
||||
};
|
||||
|
||||
const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => {
|
||||
if (method === 'GET' || method === 'HEAD') return undefined;
|
||||
|
||||
const body = getOptionBody(options);
|
||||
if (typeof body !== 'undefined') return body;
|
||||
if (isRequest(input)) return input.body;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => {
|
||||
const c = tryGetContext<any>();
|
||||
if (!c) {
|
||||
throw new Error('Hono context not found in SSR');
|
||||
}
|
||||
|
||||
const apiUrl = resolveApiUrl(input, c.req.url);
|
||||
const method = resolveMethod(input, options);
|
||||
const body = resolveBody(input, options, method.toUpperCase());
|
||||
const requestOptions: RequestInit & { duplex?: 'half' } = {
|
||||
...(isRequestLikeOptions(options) ? {} : options),
|
||||
method,
|
||||
headers: mergeHeaders(input, options),
|
||||
body,
|
||||
credentials: getOptionCredentials(options) ?? 'include',
|
||||
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
|
||||
export function httpClientAdapter(opts: {
|
||||
url: string;
|
||||
pathsForGET?: string[];
|
||||
JSON?: Partial<JsonTransformer>;
|
||||
headers?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}): TinyRpcClientAdapter {
|
||||
const JSON: JsonTransformer = {
|
||||
parse: globalThis.JSON.parse,
|
||||
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
|
||||
...opts.JSON,
|
||||
};
|
||||
return {
|
||||
send: async (data) => {
|
||||
const url = [opts.url, data.path].join("/");
|
||||
const extraHeaders = opts.headers ? await opts.headers() : {};
|
||||
const payload = JSON.stringify(data.args, (headerObj) => {
|
||||
if (headerObj) {
|
||||
Object.assign(extraHeaders, headerObj);
|
||||
}
|
||||
});
|
||||
const method = opts.pathsForGET?.includes(data.path)
|
||||
? "GET"
|
||||
: "POST";
|
||||
let req: Request;
|
||||
if (method === "GET") {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||
{
|
||||
headers: extraHeaders
|
||||
}
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
}
|
||||
let res: Response;
|
||||
if (import.meta.env.SSR) {
|
||||
const c = tryGetContext<any>();
|
||||
if (!c) {
|
||||
throw new Error("Hono context not found in SSR");
|
||||
}
|
||||
Object.entries(c.req.header()).forEach(([k, v]) => {
|
||||
req.headers.append(k, v);
|
||||
});
|
||||
res = await c.get("fetch")(req);
|
||||
} else {
|
||||
res = await fetch(req);
|
||||
}
|
||||
|
||||
if (body) {
|
||||
requestOptions.duplex = 'half';
|
||||
}
|
||||
|
||||
return fetch(apiUrl, requestOptions).then((response) => {
|
||||
const setCookies = typeof response.headers.getSetCookie === 'function'
|
||||
? response.headers.getSetCookie()
|
||||
: response.headers.get('set-cookie')
|
||||
? [response.headers.get('set-cookie')!]
|
||||
: [];
|
||||
|
||||
for (const cookie of setCookies) {
|
||||
c.header('Set-Cookie', cookie, { append: true });
|
||||
}
|
||||
|
||||
return response;
|
||||
});
|
||||
};
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
JSON.stringify({
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
data: { message: await res.text() },
|
||||
internal: true,
|
||||
})
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text(),
|
||||
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
}
|
||||
return result.value;
|
||||
},
|
||||
};
|
||||
}
|
||||
39
src/api/rpcclient.ts
Normal file
39
src/api/rpcclient.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { RpcRoutes } from "@/server/routes/rpc";
|
||||
import { proxyTinyRpc } from "@hiogawa/tiny-rpc";
|
||||
import { httpClientAdapter } from "@httpClientAdapter";
|
||||
|
||||
const endpoint = "/rpc";
|
||||
const publicEndpoint = "/rpc-public";
|
||||
const url = import.meta.env.SSR ? "http://localhost" : "";
|
||||
const publicMethods = ["login", "register", "forgotPassword", "resetPassword", "getGoogleLoginUrl"];
|
||||
// src/client/trpc-client-transformer.ts
|
||||
import {
|
||||
clientJSON
|
||||
} from "@/shared/secure-json-transformer";
|
||||
|
||||
|
||||
// export function createTrpcClientTransformer(cfg: ServerPublicKeyConfig) {
|
||||
// return {
|
||||
// input: ,
|
||||
// output: superjson,
|
||||
// };
|
||||
// }
|
||||
// const secureConfig = await fetch("/trpc-secure-config").then((r) => r.json());
|
||||
export const client = proxyTinyRpc<RpcRoutes>({
|
||||
adapter: {
|
||||
send: async (data) => {
|
||||
const targetEndpoint = publicMethods.includes(data.path) ? publicEndpoint : endpoint;
|
||||
return await httpClientAdapter({
|
||||
url: `${url}${targetEndpoint}`,
|
||||
pathsForGET: ["health"],
|
||||
JSON: {
|
||||
// parse: clientJSON.parse,
|
||||
parse: (v, fn) => JSON.parse(v),
|
||||
// stringify: clientJSON.stringify,
|
||||
stringify: (v, fn) => JSON.stringify(v),
|
||||
},
|
||||
headers: () => Promise.resolve({})
|
||||
}).send(data);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRouteLoading } from '@/composables/useRouteLoading'
|
||||
import { useRouteLoading } from '@/composables/useRouteLoading';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { visible, progress } = useRouteLoading()
|
||||
|
||||
@@ -16,7 +16,7 @@ const barStyle = computed(() => ({
|
||||
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"
|
||||
class="h-full origin-left rounded-r-full bg-primary/50 transition-[transform,opacity] duration-200 ease-out"
|
||||
:style="barStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
import Upload from "@/routes/upload/Upload.vue";
|
||||
import DashboardNav from "./DashboardNav.vue";
|
||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
import Upload from "@/routes/upload/Upload.vue";
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardNav />
|
||||
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-page md:ps-18">
|
||||
<div class=":uno: flex-1 overflow-auto p-4 bg-page rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-white md:ps-18">
|
||||
<div class=":uno: flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import LayoutDashboard from "@/components/icons/LayoutDashboard.vue";
|
||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
||||
// import Upload from "@/components/icons/Upload.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useTranslation } from "i18next-vue";
|
||||
import { computed, createStaticVNode, ref } from "vue";
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
@@ -14,40 +15,55 @@ const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
const { t } = useTranslation();
|
||||
const auth = useAuthStore();
|
||||
|
||||
const isAdmin = computed(() => String(auth.user?.role || "").toLowerCase() === "admin");
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
|
||||
const links = computed(() => [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
|
||||
// { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
|
||||
{ href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
|
||||
{ href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/settings", label: t('nav.settings'), icon: SettingsIcon, type: "a", className },
|
||||
]);
|
||||
|
||||
|
||||
//v-tooltip="i.label"
|
||||
const links = computed<Record<string, any>>(() => {
|
||||
const baseLinks = [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, action: () => {}, className },
|
||||
{ href: "/", label: t("nav.overview"), icon: Home, action: null, className },
|
||||
{ href: "/videos", label: t("nav.videos"), icon: Video, action: null, className },
|
||||
{
|
||||
href: "/notification",
|
||||
label: t("nav.notification"),
|
||||
icon: Bell,
|
||||
className,
|
||||
action: handleNotificationClick,
|
||||
isActive: isNotificationOpen,
|
||||
},
|
||||
{ href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className },
|
||||
] as const;
|
||||
return baseLinks;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||
|
||||
<template v-for="i in links" :key="i.href">
|
||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
|
||||
)">
|
||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-header transition-all duration-300 ease-in-out w-18 items-center border-r border-border text-foreground/60"
|
||||
>
|
||||
<template v-for="i in links" :key="i.href">
|
||||
<component
|
||||
:name="i.label"
|
||||
:is="i.action ? 'div' : 'router-link'"
|
||||
v-bind="i.action ? {} : { to: i.href }"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value) && 'bg-primary/15',
|
||||
)"
|
||||
>
|
||||
<component
|
||||
:is="i.icon"
|
||||
class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value"
|
||||
/>
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => (isNotificationOpen = val)" />
|
||||
</template>
|
||||
|
||||
67
src/components/OfflineOverlay.vue
Normal file
67
src/components/OfflineOverlay.vue
Normal file
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/ui/AppButton.vue'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { useTranslation } from 'i18next-vue'
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
|
||||
const { t } = useTranslation()
|
||||
const { isOffline, startListening, stopListening } = useNetworkStatus()
|
||||
|
||||
onMounted(() => {
|
||||
startListening()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopListening()
|
||||
})
|
||||
|
||||
function reloadPage() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
window.location.reload()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="isOffline"
|
||||
class="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-950/80 px-6 backdrop-blur-sm"
|
||||
role="alert"
|
||||
aria-live="assertive"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-2xl border border-border bg-white p-8 text-center shadow-2xl">
|
||||
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-danger/10 text-danger">
|
||||
<svg
|
||||
class="h-8 w-8"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M2 8.82a15 15 0 0 1 20 0" />
|
||||
<path d="M5 12.86a10 10 0 0 1 14 0" />
|
||||
<path d="M8.5 16.43a5 5 0 0 1 7 0" />
|
||||
<path d="M12 20h.01" />
|
||||
<path d="M3 3l18 18" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<h2 class="text-xl font-semibold text-foreground">
|
||||
{{ t('network.offline.title') }}
|
||||
</h2>
|
||||
|
||||
<p class="mt-3 text-sm leading-6 text-foreground/70">
|
||||
{{ t('network.offline.description') }}
|
||||
</p>
|
||||
|
||||
<div class="mt-6 flex justify-center">
|
||||
<AppButton @click="reloadPage">
|
||||
{{ t('network.offline.action') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,10 +1,12 @@
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<AppTopLoadingBar />
|
||||
<OfflineOverlay />
|
||||
</ClientOnly>
|
||||
<router-view/>
|
||||
<router-view />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
|
||||
import OfflineOverlay from '@/components/OfflineOverlay.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all press-animated select-none';
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'px-3 py-1.5 text-sm';
|
||||
case 'md':
|
||||
default:
|
||||
return 'px-4 py-2 text-sm';
|
||||
}
|
||||
});
|
||||
|
||||
const variantClass = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-muted/50 text-foreground hover:bg-muted border border-border';
|
||||
case 'danger':
|
||||
return 'bg-danger text-white hover:bg-danger/90';
|
||||
case 'ghost':
|
||||
return 'bg-transparent text-foreground/70 hover:text-foreground hover:bg-muted/50';
|
||||
case 'primary':
|
||||
default:
|
||||
return 'bg-primary text-white hover:bg-primary/90';
|
||||
}
|
||||
});
|
||||
|
||||
const disabledClass = computed(() => (props.disabled || props.loading) ? 'opacity-60 cursor-not-allowed' : '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="cn(baseClass, sizeClass, variantClass, disabledClass)"
|
||||
>
|
||||
<span v-if="loading" class="inline-flex items-center" aria-hidden="true">
|
||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,46 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'change', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return;
|
||||
const next = !props.modelValue;
|
||||
emit('update:modelValue', next);
|
||||
emit('change', next);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
:class="cn(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
modelValue ? 'bg-primary' : 'bg-border'
|
||||
)"
|
||||
>
|
||||
<span
|
||||
:class="cn(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
|
||||
modelValue ? 'translate-x-5' : 'translate-x-1'
|
||||
)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ interface Trend {
|
||||
isPositive: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
export interface StatProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string | VNode;
|
||||
@@ -15,7 +15,7 @@ interface Props {
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
withDefaults(defineProps<StatProps>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ const iconColors = {
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-surface',
|
||||
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-header',
|
||||
// gradients[color],
|
||||
'border border-gray-300 transition-all duration-300',
|
||||
// 'group cursor-pointer'
|
||||
@@ -49,7 +49,7 @@ const iconColors = {
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p>
|
||||
<p class="text-sm font-medium text-gray-600 mb-1">{{ $t(title) }}</p>
|
||||
<p class="text-3xl font-bold text-gray-900">{{ value }}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M74 42c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32V74c0-18-14-32-32-32H74zM10 74c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74zm208 256v-80h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -226 468 468">
|
||||
<path
|
||||
d="M64-184c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32v-320c0-18-14-32-32-32H64zM0-152c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H64c-35 0-64-29-64-64v-320zm208 256V24h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -242 500 516"><path d="M448-194v404l-26-24c-50-47-114-75-182-81V-89c68-6 132-34 182-81l26-24zM240 137c60 6 116 31 160 72l34 32c5 4 12 7 19 7 15 0 27-12 27-27v-425c0-16-12-28-27-28-7 0-14 3-19 8l-34 31c-50 47-116 73-185 73h-87C57-120 0-63 0 8c0 60 41 110 96 124v84c0 27 22 48 48 48h48c27 0 48-21 48-48v-79zm-40-1h8v80c0 9-7 16-16 16h-48c-9 0-16-7-16-16v-80h72zm0-224h8v192h-80c-53 0-96-43-96-96s43-96 96-96h72z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 106h512v64H10zm0 0z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<!-- Remote link icon -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564">
|
||||
@@ -15,7 +15,7 @@
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 503"><path d="M10 397v32c0 35 29 64 64 64h320c35 0 64-29 64-64v-32c0-35-29-64-64-64H266v32c0 18-14 32-32 32s-32-14-32-32v-32H74c-35 0-64 29-64 64zm392 16c0 13-11 24-24 24s-24-11-24-24 11-24 24-24 24 11 24 24z" fill="#a6acb9"/><path d="M234 397c18 0 32-14 32-32V122l41 42c13 12 33 12 46 0 12-13 12-33 0-46l-96-96c-13-12-33-12-46 0l-96 96c-12 13-12 33 0 46 13 12 33 12 46 0l41-42v243c0 18 14 32 32 32z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -260 468 502"><path d="M248 80c0 13-11 24-24 24s-24-11-24-24v-246l-63 63c-9 9-25 9-34 0s-9-25 0-34l104-104c9-9 25-9 34 0l104 104c9 9 9 25 0 34s-25 9-34 0l-63-63V80zm-96-8H64c-9 0-16 7-16 16v80c0 9 7 16 16 16h320c9 0 16-7 16-16V88c0-9-7-16-16-16h-88V24h88c35 0 64 29 64 64v80c0 35-29 64-64 64H64c-35 0-64-29-64-64V88c0-35 29-64 64-64h88v48zm168 56c0-13 11-24 24-24s24 11 24 24-11 24-24 24-24-11-24-24z" fill="#1e3050"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 503"><path d="M10 397v32c0 35 29 64 64 64h320c35 0 64-29 64-64v-32c0-35-29-64-64-64H266v32c0 18-14 32-32 32s-32-14-32-32v-32H74c-35 0-64 29-64 64zm392 16c0 13-11 24-24 24s-24-11-24-24 11-24 24-24 24 11 24 24z" fill="#a6acb9"/><path d="M234 397c18 0 32-14 32-32V122l41 42c13 12 33 12 46 0 12-13 12-33 0-46l-96-96c-13-12-33-12-46 0l-96 96c-12 13-12 33 0 46 13 12 33 12 46 0l41-42v243c0 18 14 32 32 32z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -260 468 502"><path d="M248 80c0 13-11 24-24 24s-24-11-24-24v-246l-63 63c-9 9-25 9-34 0s-9-25 0-34l104-104c9-9 25-9 34 0l104 104c9 9 9 25 0 34s-25 9-34 0l-63-63V80zm-96-8H64c-9 0-16 7-16 16v80c0 9 7 16 16 16h320c9 0 16-7 16-16V88c0-9-7-16-16-16h-88V24h88c35 0 64 29 64 64v80c0 35-29 64-64 64H64c-35 0-64-29-64-64V88c0-35 29-64 64-64h88v48zm168 56c0-13 11-24 24-24s24 11 24 24-11 24-24 24-24-11-24-24z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
||||
<path
|
||||
d="M272-242c-9-8-23-8-32 0L8-34C-2-25-3-10 6 0s24 11 34 2l8-7v205c0 35 29 64 64 64h288c35 0 64-29 64-64V-5l8 7c10 9 25 8 34-2s8-25-2-34L272-242zM416-48v248c0 9-7 16-16 16H112c-9 0-16-7-16-16V-48l160-144L416-48z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -6,12 +6,12 @@
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M394 42c18 0 32 14 32 32v64H42V74c0-18 14-32 32-32h320zM42 394V170h96v256H74c-18 0-32-14-32-32zm128 32V170h256v224c0 18-14 32-32 32H170zM74 10c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64V74c0-35-29-64-64-64H74z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" viewBox="-10 -226 468 468">
|
||||
<path
|
||||
d="M384-184c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32h320zM32 168V-56h96v256H64c-18 0-32-14-32-32zm128 32V-56h256v224c0 18-14 32-32 32H160zM64-216c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -2,15 +2,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
|
||||
<path
|
||||
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
|
||||
fill="#a6acb9" />
|
||||
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
||||
<path
|
||||
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
|
||||
fill="#1e3050" />
|
||||
fill="var(--colors-primary-DEFAULT)" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
|
||||
<path
|
||||
d="M240-216c-88 0-160 72-160 160 0 5 0 10 1 15C33-18 0 31 0 88c0 80 65 144 144 144h304c71 0 128-57 128-128 0-50-28-93-70-114 4-12 6-25 6-38 0-66-54-120-120-120-11 0-23 2-33 5-30-33-72-53-119-53zM128-56c0-62 50-112 112-112 38 0 71 19 91 47 7 10 20 13 30 8 9-4 20-7 31-7 40 0 72 32 72 72 0 14-4 27-11 38-4 7-5 15-2 22s9 13 16 14c35 9 61 41 61 78 0 44-36 80-80 80H144c-53 0-96-43-96-96 0-43 28-79 67-91 11-4 18-16 16-29-2-7-3-16-3-24zm177 7c-9-9-25-9-34 0l-64 64c-9 9-9 25 0 34 10 9 25 9 34 0l23-23v86c0 13 11 24 24 24s24-11 24-24V26l23 23c9 9 25 9 34 0s9-25 0-34l-64-64z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
10
src/components/icons/UserIcon copy.vue
Normal file
10
src/components/icons/UserIcon copy.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
@@ -5,6 +5,6 @@ defineProps<{
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="#1e3050"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
||||
<path
|
||||
d="M96-136c-9 0-16 7-16 16v256c0 9 7 16 16 16h256c9 0 16-7 16-16v-256c0-9-7-16-16-16H96zm-64 16c0-35 29-64 64-64h256c35 0 64 29 64 64v256c0 35-29 64-64 64H96c-35 0-64-29-64-64v-256zm506-11c4-3 9-5 14-5 13 0 24 11 24 24v240c0 13-11 24-24 24-5 0-10-2-14-5l-74-55V32l64 48V-64l-64 48v-60l74-55z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M42 170h241c-40 35-65 87-65 144 0 17 2 33 6 48H74c-18 0-32-14-32-32V170z" fill="#a6acb9"/><path d="M458 42H345l-96 96h84c-18 8-35 19-50 32H42v160c0 18 14 32 32 32h150c3 11 7 22 11 32H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3V74c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM43 138l96-96H74c-18 0-32 14-32 32v64h1zm46 0h114l96-96H185l-96 96zm321 288c62 0 112-50 112-112s-50-112-112-112-112 50-112 112 50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144 64-144 144-144zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V258c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="564" height="468" viewBox="22 -194 564 468"><path d="M480-152H367l-96 96h84c-18 8-35 19-50 32H64v160c0 18 14 32 32 32h150c3 11 7 22 11 32H96c-35 0-64-29-64-64v-256c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3v-64c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM65-56l96-96H96c-18 0-32 14-32 32v64h1zm46 0h114l96-96H207l-96 96zm321 288c62 0 112-50 112-112S494 8 432 8 320 58 320 120s50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144S352-24 432-24zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V64c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M42 170h241c-40 35-65 87-65 144 0 17 2 33 6 48H74c-18 0-32-14-32-32V170z" fill="#a6acb9"/><path d="M458 42H345l-96 96h84c-18 8-35 19-50 32H42v160c0 18 14 32 32 32h150c3 11 7 22 11 32H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3V74c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM43 138l96-96H74c-18 0-32 14-32 32v64h1zm46 0h114l96-96H185l-96 96zm321 288c62 0 112-50 112-112s-50-112-112-112-112 50-112 112 50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144 64-144 144-144zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V258c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="564" height="468" viewBox="22 -194 564 468"><path d="M480-152H367l-96 96h84c-18 8-35 19-50 32H64v160c0 18 14 32 32 32h150c3 11 7 22 11 32H96c-35 0-64-29-64-64v-256c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3v-64c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM65-56l96-96H96c-18 0-32 14-32 32v64h1zm46 0h114l96-96H207l-96 96zm321 288c62 0 112-50 112-112S494 8 432 8 320 58 320 120s50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144S352-24 432-24zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V64c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
|
||||
61
src/components/ui/AppButton.vue
Normal file
61
src/components/ui/AppButton.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type UiButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: UiButtonVariant;
|
||||
size?: UiButtonSize;
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
block: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
type: 'button',
|
||||
},
|
||||
);
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.loading);
|
||||
|
||||
const classes = computed(() => {
|
||||
const variants: Record<UiButtonVariant, string> = {
|
||||
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
|
||||
secondary: 'border-border bg-white text-text hover:bg-header focus-visible:ring-#0969da/20',
|
||||
ghost: 'border-transparent bg-transparent text-text hover:bg-header focus-visible:ring-#0969da/20 shadow-none',
|
||||
danger: 'border-transparent bg-danger text-white hover:opacity-92 focus-visible:ring-danger/20',
|
||||
};
|
||||
|
||||
const sizes: Record<UiButtonSize, string> = {
|
||||
sm: 'min-h-[28px] px-3 text-[12px] leading-[20px]',
|
||||
md: 'min-h-[32px] px-3 text-[14px] leading-[20px]',
|
||||
lg: 'min-h-[36px] px-4 text-[14px] leading-[20px]',
|
||||
};
|
||||
|
||||
return [
|
||||
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
|
||||
variants[props.variant],
|
||||
sizes[props.size],
|
||||
props.block ? 'w-full' : '',
|
||||
].join(' ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="isDisabled" :class="classes" :aria-busy="loading || undefined">
|
||||
<span
|
||||
v-if="loading"
|
||||
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<slot v-else name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
const isMounted = ref(false);
|
||||
@@ -75,7 +75,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div :class="cn('w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
||||
<div :class="cn('w-full bg-header border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
||||
<!-- Header slot -->
|
||||
<div v-if="$slots.header" class="px-5 py-4 border-b border-border">
|
||||
<slot name="header" :close="close" />
|
||||
@@ -61,7 +61,7 @@ const onKeyup = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') emit('enter');
|
||||
};
|
||||
|
||||
const baseInputClass = 'w-full px-3 py-2 rounded-md border border-border bg-surface text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
const baseInputClass = 'w-full px-3 py-2 rounded-md border border-border bg-header text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
55
src/components/ui/AppSwitch.vue
Normal file
55
src/components/ui/AppSwitch.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface SwitchProps {
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
class?: string; // Đổi từ className sang class cho chuẩn Vue
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<SwitchProps>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
// Vue 3.4+ - Quản lý v-model cực gọn
|
||||
const modelValue = defineModel<boolean>({ default: false });
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'change', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return;
|
||||
modelValue.value = !modelValue.value;
|
||||
emit('change', modelValue.value);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
:class="cn(
|
||||
// Layout & Size
|
||||
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200',
|
||||
// Focus states (UnoCSS style)
|
||||
'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
|
||||
// Status states
|
||||
disabled ? 'op-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
modelValue ? 'bg-primary' : 'bg-gray-200 dark:bg-dark-300',
|
||||
props.class
|
||||
)"
|
||||
>
|
||||
<span
|
||||
:class="cn(
|
||||
// Toggle thumb
|
||||
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200',
|
||||
modelValue ? 'translate-x-5' : 'translate-x-0'
|
||||
)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
75
src/components/ui/AsyncSelect.vue
Normal file
75
src/components/ui/AsyncSelect.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
interface SelectOption {
|
||||
label: string;
|
||||
value: string | number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
loadOptions: () => Promise<SelectOption[]>;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
placeholder: 'Please select...',
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string | number>();
|
||||
|
||||
const options = ref<SelectOption[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const fetchData = async () => {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
options.value = await props.loadOptions();
|
||||
} catch {
|
||||
error.value = 'Failed to load options';
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(fetchData);
|
||||
watch(() => props.loadOptions, fetchData);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div class="relative w-full">
|
||||
<select
|
||||
v-model="modelValue"
|
||||
:disabled="loading || disabled"
|
||||
class="w-full appearance-none rounded-md border border-border bg-header px-3 py-2 pr-10 text-sm text-foreground outline-none transition-all focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<option value="" disabled>{{ placeholder }}</option>
|
||||
<option
|
||||
v-for="opt in options"
|
||||
:key="opt.value"
|
||||
:value="opt.value"
|
||||
>
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-foreground/40">
|
||||
<div v-if="loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent" />
|
||||
<div v-else class="i-carbon-chevron-down text-lg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="error"
|
||||
type="button"
|
||||
@click="fetchData"
|
||||
class="text-xs font-medium text-danger transition hover:opacity-80"
|
||||
>
|
||||
{{ error }} · Retry
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
153
src/components/ui/BaseTable.vue
Normal file
153
src/components/ui/BaseTable.vue
Normal file
@@ -0,0 +1,153 @@
|
||||
<script setup lang="ts" generic="TData extends Record<string, any>">
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
FlexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useVueTable,
|
||||
type ColumnMeta,
|
||||
type ColumnDef,
|
||||
type Row,
|
||||
type SortingState,
|
||||
type Updater,
|
||||
} from '@tanstack/vue-table';
|
||||
|
||||
type TableColumnMeta = ColumnMeta<TData, any> & {
|
||||
headerClass?: string;
|
||||
cellClass?: string;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
data: TData[];
|
||||
columns: ColumnDef<TData, any>[];
|
||||
loading?: boolean;
|
||||
emptyText?: string;
|
||||
tableClass?: string;
|
||||
wrapperClass?: string;
|
||||
headerRowClass?: string;
|
||||
bodyRowClass?: string | ((row: Row<TData>) => string | undefined);
|
||||
getRowId?: (originalRow: TData, index: number) => string;
|
||||
}>(), {
|
||||
loading: false,
|
||||
emptyText: 'No data available.',
|
||||
});
|
||||
|
||||
const sorting = ref<SortingState>([]);
|
||||
|
||||
function updateSorting(updaterOrValue: Updater<SortingState>) {
|
||||
sorting.value = typeof updaterOrValue === 'function'
|
||||
? updaterOrValue(sorting.value)
|
||||
: updaterOrValue;
|
||||
}
|
||||
|
||||
const table = useVueTable<TData>({
|
||||
get data() {
|
||||
return props.data;
|
||||
},
|
||||
get columns() {
|
||||
return props.columns;
|
||||
},
|
||||
getRowId: props.getRowId,
|
||||
state: {
|
||||
get sorting() {
|
||||
return sorting.value;
|
||||
},
|
||||
},
|
||||
onSortingChange: updateSorting,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
function resolveBodyRowClass(row: Row<TData>) {
|
||||
return typeof props.bodyRowClass === 'function'
|
||||
? props.bodyRowClass(row)
|
||||
: props.bodyRowClass;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('overflow-x-auto rounded-xl border border-gray-200 bg-white', wrapperClass)">
|
||||
<table :class="cn('w-full min-w-[48rem] border-collapse', tableClass)">
|
||||
<thead class="bg-header">
|
||||
<tr
|
||||
v-for="headerGroup in table.getHeaderGroups()"
|
||||
:key="headerGroup.id"
|
||||
:class="cn('border-b border-gray-200', headerRowClass)"
|
||||
>
|
||||
<th
|
||||
v-for="header in headerGroup.headers"
|
||||
:key="header.id"
|
||||
:class="cn(
|
||||
'px-4 py-3 text-left text-sm font-medium text-gray-600',
|
||||
header.column.getCanSort() && !header.isPlaceholder && 'cursor-pointer select-none',
|
||||
(header.column.columnDef.meta as TableColumnMeta | undefined)?.headerClass
|
||||
)"
|
||||
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<FlexRender
|
||||
v-if="!header.isPlaceholder"
|
||||
:render="header.column.columnDef.header"
|
||||
:props="header.getContext()"
|
||||
/>
|
||||
<span
|
||||
v-if="header.column.getCanSort()"
|
||||
class="text-[10px] uppercase tracking-wide text-gray-400"
|
||||
>
|
||||
{{ header.column.getIsSorted() === 'asc' ? 'asc' : header.column.getIsSorted() === 'desc' ? 'desc' : '' }}
|
||||
</span>
|
||||
</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
<tr v-if="loading">
|
||||
<td
|
||||
:colspan="columns.length || 1"
|
||||
class="px-4 py-10 text-center text-sm text-gray-500"
|
||||
>
|
||||
<slot name="loading">
|
||||
Loading...
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr v-else-if="!table.getRowModel().rows.length">
|
||||
<td
|
||||
:colspan="columns.length || 1"
|
||||
class="px-4 py-10 text-center text-sm text-gray-500"
|
||||
>
|
||||
<slot name="empty">
|
||||
{{ emptyText }}
|
||||
</slot>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr
|
||||
v-for="row in table.getRowModel().rows"
|
||||
v-else
|
||||
:key="row.id"
|
||||
:class="cn(
|
||||
'border-b border-gray-200 transition-colors last:border-b-0 hover:bg-gray-50',
|
||||
resolveBodyRowClass(row)
|
||||
)"
|
||||
>
|
||||
<td
|
||||
v-for="cell in row.getVisibleCells()"
|
||||
:key="cell.id"
|
||||
:class="cn(
|
||||
'px-4 py-3 align-middle',
|
||||
(cell.column.columnDef.meta as TableColumnMeta | undefined)?.cellClass
|
||||
)"
|
||||
>
|
||||
<FlexRender
|
||||
:render="cell.column.columnDef.cell"
|
||||
:props="cell.getContext()"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
58
src/composables/useAdminRuntimeMqtt.ts
Normal file
58
src/composables/useAdminRuntimeMqtt.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { TinyMqttClient } from "@/lib/liteMqtt";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { computed, onBeforeUnmount, watch } from "vue";
|
||||
|
||||
type RuntimeMessage = {
|
||||
topic: string;
|
||||
payload: any;
|
||||
};
|
||||
|
||||
const mqttBrokerUrl = "wss://mqtt-dashboard.com:8884/mqtt";
|
||||
|
||||
export function useAdminRuntimeMqtt(onMessage: (message: RuntimeMessage) => void) {
|
||||
const auth = useAuthStore();
|
||||
let client: TinyMqttClient | undefined;
|
||||
|
||||
const isAdmin = computed(() => auth.user?.role?.toUpperCase?.() === "ADMIN");
|
||||
|
||||
const connect = () => {
|
||||
if (import.meta.env.SSR || !isAdmin.value) return;
|
||||
disconnect();
|
||||
client = new TinyMqttClient(
|
||||
mqttBrokerUrl,
|
||||
["picpic/events", "picpic/logs/#", "picpic/job/+"],
|
||||
(topic, raw) => {
|
||||
try {
|
||||
onMessage({ topic, payload: JSON.parse(raw) });
|
||||
} catch {
|
||||
onMessage({ topic, payload: raw });
|
||||
}
|
||||
},
|
||||
);
|
||||
client.connect();
|
||||
};
|
||||
|
||||
const disconnect = () => {
|
||||
client?.disconnect();
|
||||
client = undefined;
|
||||
};
|
||||
|
||||
const stopWatch = watch(
|
||||
() => [auth.user?.id, auth.user?.role],
|
||||
() => {
|
||||
if (isAdmin.value) {
|
||||
connect();
|
||||
} else {
|
||||
disconnect();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopWatch();
|
||||
disconnect();
|
||||
});
|
||||
|
||||
return { disconnect };
|
||||
}
|
||||
47
src/composables/useNetworkStatus.ts
Normal file
47
src/composables/useNetworkStatus.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const isOffline = ref(false)
|
||||
|
||||
let listenersCount = 0
|
||||
|
||||
function syncNetworkStatus() {
|
||||
if (typeof navigator === 'undefined') return
|
||||
|
||||
isOffline.value = !navigator.onLine
|
||||
}
|
||||
|
||||
function handleNetworkStatusChange() {
|
||||
syncNetworkStatus()
|
||||
}
|
||||
|
||||
function startListening() {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
if (listenersCount === 0) {
|
||||
syncNetworkStatus()
|
||||
window.addEventListener('online', handleNetworkStatusChange)
|
||||
window.addEventListener('offline', handleNetworkStatusChange)
|
||||
}
|
||||
|
||||
listenersCount += 1
|
||||
}
|
||||
|
||||
function stopListening() {
|
||||
if (typeof window === 'undefined' || listenersCount === 0) return
|
||||
|
||||
listenersCount -= 1
|
||||
|
||||
if (listenersCount === 0) {
|
||||
window.removeEventListener('online', handleNetworkStatusChange)
|
||||
window.removeEventListener('offline', handleNetworkStatusChange)
|
||||
}
|
||||
}
|
||||
|
||||
export function useNetworkStatus() {
|
||||
return {
|
||||
isOffline,
|
||||
syncNetworkStatus,
|
||||
startListening,
|
||||
stopListening,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
@@ -24,9 +24,7 @@ type NotificationApiItem = {
|
||||
read?: boolean;
|
||||
actionUrl?: string;
|
||||
actionLabel?: string;
|
||||
action_url?: string;
|
||||
action_label?: string;
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const notifications = ref<AppNotification[]>([]);
|
||||
@@ -69,18 +67,18 @@ export function useNotifications() {
|
||||
type: normalizeType(item.type),
|
||||
title: item.title || '',
|
||||
message: item.message || '',
|
||||
time: formatRelativeTime(item.created_at),
|
||||
time: formatRelativeTime(item.createdAt),
|
||||
read: Boolean(item.read),
|
||||
actionUrl: item.actionUrl || item.action_url || undefined,
|
||||
actionLabel: item.actionLabel || item.action_label || undefined,
|
||||
createdAt: item.created_at,
|
||||
actionUrl: item.actionUrl || undefined,
|
||||
actionLabel: item.actionLabel || undefined,
|
||||
createdAt: item.createdAt,
|
||||
});
|
||||
|
||||
const fetchNotifications = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
|
||||
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
|
||||
const response = await rpcClient.listNotifications();
|
||||
notifications.value = (response.notifications || []).map(mapNotification);
|
||||
loaded.value = true;
|
||||
return notifications.value;
|
||||
} finally {
|
||||
@@ -90,24 +88,24 @@ export function useNotifications() {
|
||||
|
||||
const markRead = async (id: string) => {
|
||||
if (!id) return;
|
||||
await client.notifications.readCreate(id, { baseUrl: '/r' });
|
||||
await rpcClient.markNotificationRead({ id });
|
||||
const item = notifications.value.find(notification => notification.id === id);
|
||||
if (item) item.read = true;
|
||||
};
|
||||
|
||||
const deleteNotification = async (id: string) => {
|
||||
if (!id) return;
|
||||
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
|
||||
await rpcClient.deleteNotification({ id });
|
||||
notifications.value = notifications.value.filter(notification => notification.id !== id);
|
||||
};
|
||||
|
||||
const markAllRead = async () => {
|
||||
await client.notifications.readAllCreate({ baseUrl: '/r' });
|
||||
await rpcClient.markAllNotificationsRead();
|
||||
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
|
||||
};
|
||||
|
||||
const clearAll = async () => {
|
||||
await client.notifications.notificationsDelete({ baseUrl: '/r' });
|
||||
await rpcClient.clearNotifications();
|
||||
notifications.value = [];
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Preferences } from '@/server/gen/proto/app/v1/common';
|
||||
import type { UpdatePreferencesRequest } from '@/server/gen/proto/app/v1/account';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
|
||||
@@ -8,13 +10,8 @@ export type SettingsPreferencesSnapshot = {
|
||||
pushNotifications: boolean;
|
||||
marketingNotifications: boolean;
|
||||
telegramNotifications: boolean;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
showControls: boolean;
|
||||
pip: boolean;
|
||||
airplay: boolean;
|
||||
chromecast: boolean;
|
||||
language: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export type NotificationSettingsDraft = {
|
||||
@@ -24,21 +21,8 @@ export type NotificationSettingsDraft = {
|
||||
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;
|
||||
};
|
||||
preferences?: Preferences;
|
||||
};
|
||||
|
||||
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||
@@ -46,30 +30,20 @@ const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
||||
pushNotifications: true,
|
||||
marketingNotifications: false,
|
||||
telegramNotifications: false,
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
muted: false,
|
||||
showControls: true,
|
||||
pip: true,
|
||||
airplay: true,
|
||||
chromecast: true,
|
||||
language: 'en',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
|
||||
const preferences = (responseData as PreferencesResponse | undefined)?.data?.preferences;
|
||||
const preferences = (responseData as PreferencesResponse | undefined)?.preferences;
|
||||
|
||||
return {
|
||||
emailNotifications: preferences?.email_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
||||
pushNotifications: preferences?.push_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||
marketingNotifications: preferences?.marketing_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||
telegramNotifications: preferences?.telegram_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||
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,
|
||||
emailNotifications: preferences?.emailNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
||||
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
||||
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
||||
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
||||
language: preferences?.language ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.language,
|
||||
locale: preferences?.locale ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.locale,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -82,46 +56,21 @@ export const createNotificationSettingsDraft = (
|
||||
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,
|
||||
): UpdatePreferencesRequest => ({
|
||||
emailNotifications: draft.email,
|
||||
pushNotifications: draft.push,
|
||||
marketingNotifications: draft.marketing,
|
||||
telegramNotifications: draft.telegram,
|
||||
});
|
||||
|
||||
export function useSettingsPreferencesQuery() {
|
||||
return useQuery({
|
||||
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
|
||||
query: async () => {
|
||||
const response = await client.settings.preferencesList({ baseUrl: '/r' });
|
||||
return normalizePreferencesSnapshot(response.data);
|
||||
const response = await rpcClient.getPreferences();
|
||||
return normalizePreferencesSnapshot(response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client, ContentType } from '@/api/client';
|
||||
import { client } from '@/api/rpcclient';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export interface QueueItem {
|
||||
@@ -13,9 +13,6 @@ export interface QueueItem {
|
||||
thumbnail?: string;
|
||||
file?: File; // Keep reference to file for local uploads
|
||||
url?: string; // Keep reference to url for remote uploads
|
||||
playbackUrl?: string;
|
||||
videoId?: string;
|
||||
mergeId?: string;
|
||||
// Upload chunk tracking
|
||||
activeChunks?: number;
|
||||
uploadedUrls?: string[];
|
||||
@@ -44,7 +41,6 @@ const abortItem = (id: string) => {
|
||||
};
|
||||
|
||||
export function useUploadQueue() {
|
||||
const t = (key: string, params?: Record<string, unknown>) => key;
|
||||
|
||||
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
|
||||
|
||||
@@ -87,12 +83,12 @@ export function useUploadQueue() {
|
||||
const duplicateCount = allowed.length - fresh.length;
|
||||
const newItems: QueueItem[] = fresh.map((url) => ({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: url.split('/').pop() || t('upload.queueItem.remoteFileName'),
|
||||
name: url.split('/').pop() || 'Remote File',
|
||||
type: 'remote',
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
uploaded: '0 MB',
|
||||
total: t('upload.queueItem.unknownSize'),
|
||||
total: 'Unknown',
|
||||
speed: '0 MB/s',
|
||||
url: url,
|
||||
activeChunks: 0,
|
||||
@@ -272,7 +268,7 @@ export function useUploadQueue() {
|
||||
setTimeout(attempt, 2000);
|
||||
} else {
|
||||
item.status = 'error';
|
||||
reject(new Error(t('upload.errors.chunkUploadFailed', { index: index + 1 })));
|
||||
reject(new Error(`Failed to upload chunk ${index + 1}`));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,41 +283,19 @@ export function useUploadQueue() {
|
||||
if (!item.file || !item.uploadedUrls) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: item.file.name,
|
||||
chunks: item.uploadedUrls,
|
||||
size: item.file.size
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || t('upload.errors.mergeFailed'));
|
||||
const data = await client.merge(item.file.name, item.uploadedUrls, item.file.size);
|
||||
// const response = await fetch('/merge', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({
|
||||
// filename: item.file.name,
|
||||
// chunks: item.uploadedUrls,
|
||||
// size: item.file.size
|
||||
// })
|
||||
// });
|
||||
if (!data) {
|
||||
throw new Error('No response from server');
|
||||
}
|
||||
|
||||
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.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
@@ -351,8 +325,7 @@ export function useUploadQueue() {
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||
return `${value} ${sizes[i]}`;
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const totalSize = computed(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
|
||||
export const USAGE_QUERY_KEY = ['usage'] as const;
|
||||
@@ -9,10 +9,8 @@ export type UsageSnapshot = {
|
||||
};
|
||||
|
||||
type UsageResponse = {
|
||||
data?: {
|
||||
total_videos?: number;
|
||||
total_storage?: number;
|
||||
};
|
||||
totalVideos?: number;
|
||||
totalStorage?: number;
|
||||
};
|
||||
|
||||
const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
||||
@@ -21,11 +19,11 @@ const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
||||
};
|
||||
|
||||
const normalizeUsageSnapshot = (responseData: unknown): UsageSnapshot => {
|
||||
const usage = (responseData as UsageResponse | undefined)?.data;
|
||||
const usage = responseData as UsageResponse | undefined;
|
||||
|
||||
return {
|
||||
totalVideos: usage?.total_videos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
||||
totalStorage: usage?.total_storage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
||||
totalVideos: usage?.totalVideos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
||||
totalStorage: usage?.totalStorage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,8 +31,8 @@ export function useUsageQuery() {
|
||||
return useQuery({
|
||||
key: () => USAGE_QUERY_KEY,
|
||||
query: async () => {
|
||||
const response = await client.usage.usageList({ baseUrl: '/r' });
|
||||
return normalizeUsageSnapshot(response.data);
|
||||
const response = await rpcClient.getUsage();
|
||||
return normalizeUsageSnapshot(response);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,25 +1,19 @@
|
||||
import { Hono } from 'hono';
|
||||
|
||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
||||
import { setupMiddlewares } from './server/middlewares/setup';
|
||||
import { registerDisplayRoutes } from './server/routes/display';
|
||||
import { registerManifestRoutes } from './server/routes/manifest';
|
||||
import { registerMergeRoutes } from './server/routes/merge';
|
||||
import { registerAuthRoutes } from './server/routes/auth';
|
||||
import { registerRpcRoutes } from './server/routes/rpc';
|
||||
import { registerSSRRoutes } from './server/routes/ssr';
|
||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
||||
|
||||
import { setupServices } from './server/services/grpcClient';
|
||||
const app = new Hono();
|
||||
|
||||
// Global middlewares
|
||||
setupMiddlewares(app);
|
||||
|
||||
// API proxy middleware (handles /r/*)
|
||||
app.use(apiProxyMiddleware);
|
||||
setupServices(app);
|
||||
// Routes
|
||||
registerWellKnownRoutes(app);
|
||||
registerMergeRoutes(app);
|
||||
registerDisplayRoutes(app);
|
||||
registerManifestRoutes(app);
|
||||
registerAuthRoutes(app);
|
||||
registerRpcRoutes(app);
|
||||
registerSSRRoutes(app);
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -97,3 +97,7 @@ export const getStatusSeverity = (status: string = "") => {
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
export const isAdmin = (role: string = "") => {
|
||||
const r = String(role).toLowerCase();
|
||||
return r === "admin" || r === "superadmin";
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ModelVideo } from "@/api/client";
|
||||
import type { Video as ModelVideo } from "@/server/gen/proto/app/v1/common";
|
||||
|
||||
export const mockVideos: ModelVideo[] = [
|
||||
{
|
||||
@@ -9,7 +9,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 345, // 5m 45s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 45, // 45MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
|
||||
views: 12500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -20,9 +20,8 @@ export const mockVideos: ModelVideo[] = [
|
||||
thumbnail: 'https://picsum.photos/seed/video2/640/360',
|
||||
duration: 890, // 14m 50s
|
||||
status: 'processing',
|
||||
processing_status: '75%',
|
||||
size: 1024 * 1024 * 128, // 128MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
@@ -34,7 +33,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 120, // 2m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 25, // 25MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
|
||||
views: 340,
|
||||
url: '#'
|
||||
},
|
||||
@@ -46,7 +45,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 1800, // 30m 00s
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 350, // 350MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
|
||||
views: 12,
|
||||
url: '#'
|
||||
},
|
||||
@@ -58,7 +57,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 600, // 10m 00s
|
||||
status: 'failed',
|
||||
size: 1024 * 1024 * 80, // 80MB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
|
||||
views: 0,
|
||||
url: '#'
|
||||
},
|
||||
@@ -70,7 +69,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 5400, // 1h 30m
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 1024 * 2.5, // 2.5GB
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
|
||||
views: 45000,
|
||||
url: '#'
|
||||
},
|
||||
@@ -82,7 +81,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 1540,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 200,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
|
||||
views: 8900,
|
||||
url: '#'
|
||||
},
|
||||
@@ -94,7 +93,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -106,7 +105,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -118,7 +117,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -130,7 +129,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -142,7 +141,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -154,7 +153,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -166,7 +165,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -178,7 +177,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -190,7 +189,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -202,7 +201,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -214,7 +213,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -226,7 +225,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -238,7 +237,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -250,7 +249,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -262,7 +261,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -274,7 +273,7 @@ export const mockVideos: ModelVideo[] = [
|
||||
duration: 3200,
|
||||
status: 'ready',
|
||||
size: 1024 * 1024 * 800,
|
||||
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
|
||||
views: 1500,
|
||||
url: '#'
|
||||
},
|
||||
@@ -340,7 +339,7 @@ export const updateMockVideo = async (id: string, updates: { title: string; desc
|
||||
...mockVideos[videoIndex],
|
||||
title: updates.title,
|
||||
description: updates.description,
|
||||
updated_at: new Date().toISOString()
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
return mockVideos[videoIndex];
|
||||
};
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { reactive } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -59,7 +59,7 @@ const onFormSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
client.auth.forgotPasswordCreate({ email: form.email })
|
||||
rpcClient.forgotPassword({ email: form.email })
|
||||
.then(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
@@ -20,11 +20,9 @@ const status = computed(() => String(route.query.status ?? 'error'));
|
||||
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
|
||||
|
||||
const reasonMessages: Record<string, string> = {
|
||||
missing_state: 'Google login session is invalid. Please try again.',
|
||||
invalid_state: 'Google login session has expired. Please try again.',
|
||||
missing_code: 'Google did not return an authorization code.',
|
||||
access_denied: 'Google login was cancelled.',
|
||||
exchange_failed: 'Failed to sign in with Google.',
|
||||
exchange_failed: 'Failed to verify your Google sign-in. Please try again.',
|
||||
userinfo_failed: 'Failed to load your Google account information.',
|
||||
userinfo_parse_failed: 'Failed to read your Google account information.',
|
||||
missing_email: 'Your Google account did not provide an email address.',
|
||||
|
||||
@@ -37,8 +37,28 @@
|
||||
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="refUsername" class="rounded-lg border border-blue-200 bg-blue-50 px-3 py-2 text-sm text-blue-700">
|
||||
Signing up with referral: <span class="font-medium">@{{ refUsername }}</span>
|
||||
</div>
|
||||
|
||||
<AppButton type="submit" class="w-full">{{ t('auth.signup.createAccount') }}</AppButton>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
<div class="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-sm">
|
||||
<span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2" @click="signupWithGoogle">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
Continue with Google
|
||||
</AppButton>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
{{ t('auth.signup.alreadyHave') }}
|
||||
<router-link to="/login"
|
||||
@@ -50,13 +70,16 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { z } from 'zod';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const route = useRoute();
|
||||
const showPassword = ref(false);
|
||||
const { t } = useTranslation();
|
||||
const refUsername = computed(() => String(route.query.ref || '').trim());
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
@@ -86,6 +109,10 @@ const onFormSubmit = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
auth.register(form.name, form.email, form.password);
|
||||
auth.register(form.name, form.email, form.password, refUsername.value || undefined);
|
||||
};
|
||||
|
||||
const signupWithGoogle = () => {
|
||||
auth.loginWithGoogle(refUsername.value || undefined);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -97,7 +97,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
<div
|
||||
v-for="signal in signalItems"
|
||||
:key="signal.label"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm"
|
||||
class="rounded-2xl border border-slate-200 bg-white px-5 py-4"
|
||||
>
|
||||
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
{{ signal.label }}
|
||||
@@ -211,7 +211,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</div>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-3">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-8 -258 529 532" fill="none">
|
||||
<path
|
||||
@@ -228,7 +228,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-violet-50 text-violet-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 570 570" fill="none">
|
||||
<path
|
||||
@@ -237,7 +237,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
/>
|
||||
<path
|
||||
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
|
||||
fill="#1e3050"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
@@ -249,7 +249,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 shadow-sm transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<article class="rounded-3xl border border-slate-200 bg-white p-8 transition-all duration-200 ease-out hover:-translate-y-1 hover:shadow-[0_18px_44px_rgba(15,23,42,0.08)]">
|
||||
<div class="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-amber-50 text-amber-600">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="-10 -226 532 468" fill="none">
|
||||
<path
|
||||
@@ -267,7 +267,7 @@ const isScalePack = (tag: string) => tag === scaleTag.value;
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm sm:p-8">
|
||||
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 sm:p-8">
|
||||
<div class="grid gap-6 lg:grid-cols-[1fr_0.9fr] lg:items-center">
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "vue-router";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean; requiresAdmin?: boolean };
|
||||
children?: RouteData[];
|
||||
};
|
||||
const routes: RouteData[] = [
|
||||
@@ -64,6 +64,11 @@ const routes: RouteData[] = [
|
||||
name: "signup",
|
||||
component: () => import("./auth/signup.vue"),
|
||||
},
|
||||
{
|
||||
path: "ref/:username",
|
||||
name: "referral-entry",
|
||||
beforeEnter: (to) => ({ name: "signup", query: { ref: String(to.params.username || "") } }),
|
||||
},
|
||||
{
|
||||
path: "forgot",
|
||||
name: "forgot",
|
||||
@@ -177,13 +182,7 @@ const routes: RouteData[] = [
|
||||
},
|
||||
{
|
||||
path: "player",
|
||||
name: "settings-player",
|
||||
component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Player Settings - Holistream",
|
||||
},
|
||||
},
|
||||
redirect: { name: "settings-player-configs" },
|
||||
},
|
||||
{
|
||||
path: "domains",
|
||||
@@ -205,6 +204,16 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "player-configs",
|
||||
name: "settings-player-configs",
|
||||
component: () => import("./settings/PlayerConfigs/PlayerConfigs.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Player Configs - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "danger",
|
||||
name: "settings-danger",
|
||||
@@ -215,6 +224,22 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "admin",
|
||||
meta: { requiresAdmin: true },
|
||||
redirect: { name: "admin-overview" },
|
||||
children: [
|
||||
{ path: "users", name: "admin-users", component: () => import("./settings/admin/Users.vue") },
|
||||
{ path: "videos", name: "admin-videos", component: () => import("./settings/admin/Videos.vue") },
|
||||
{ path: "payments", name: "admin-payments", component: () => import("./settings/admin/Payments.vue") },
|
||||
{ path: "plans", name: "admin-plans", component: () => import("./settings/admin/Plans.vue") },
|
||||
{ path: "ad-templates", name: "admin-ad-templates", component: () => import("./settings/admin/AdTemplates.vue") },
|
||||
{ path: "player-configs", name: "admin-player-configs", component: () => import("./settings/admin/PlayerConfigs.vue") },
|
||||
{ path: "jobs", name: "admin-jobs", component: () => import("./settings/admin/Jobs.vue") },
|
||||
{ path: "agents", name: "admin-agents", component: () => import("./settings/admin/Agents.vue") },
|
||||
{ path: "logs", name: "admin-logs", component: () => import("./settings/admin/Logs.vue") },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
@@ -254,6 +279,17 @@ const createAppRouter = () => {
|
||||
return { name: "login" };
|
||||
}
|
||||
}
|
||||
|
||||
if (to.matched.some((record) => record.meta.requiresAdmin)) {
|
||||
if (!auth.user) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
const role = String(auth.user.role || "").toLowerCase();
|
||||
if (role !== "admin") {
|
||||
return { name: "overview" };
|
||||
}
|
||||
}
|
||||
});
|
||||
router.afterEach(() => {
|
||||
loading.finish()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
@@ -7,32 +8,42 @@ import NameGradient from './components/NameGradient.vue';
|
||||
import QuickActions from './components/QuickActions.vue';
|
||||
import RecentVideos from './components/RecentVideos.vue';
|
||||
import StatsOverview from './components/StatsOverview.vue';
|
||||
|
||||
import type { StatProps } from '@/components/dashboard/StatsCard.vue';
|
||||
import { formatBytes, isAdmin } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
const AdminOverview = defineAsyncComponent(() => import('./components/AdminOverview.vue'));
|
||||
const {t} = useTranslation()
|
||||
const auth = useAuthStore();
|
||||
const recentVideosLoading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
|
||||
const { data: usageSnapshot, isPending: isUsagePending, refresh } = useUsageQuery();
|
||||
|
||||
const stats = computed(() => ({
|
||||
totalVideos: usageSnapshot.value?.totalVideos ?? 0,
|
||||
totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||
storageUsed: usageSnapshot.value?.totalStorage ?? 0,
|
||||
storageLimit: 10737418240,
|
||||
}));
|
||||
const stats = computed<StatProps[]>(() => [
|
||||
{
|
||||
title: 'overview.stats.totalVideos',
|
||||
value: usageSnapshot.value?.totalVideos ?? 0,
|
||||
trend: { value: 12, isPositive: true }
|
||||
},
|
||||
{
|
||||
title: 'overview.stats.totalViews',
|
||||
value: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
|
||||
trend: { value: 8, isPositive: true }
|
||||
},
|
||||
{
|
||||
title: 'overview.stats.storageUsed',
|
||||
value: `${formatBytes(usageSnapshot.value?.totalStorage ?? 0)} / ${t('overview.stats.unlimited')}`,
|
||||
color: 'warning',
|
||||
trend: { value: 5, isPositive: false }
|
||||
}
|
||||
]);
|
||||
const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
recentVideosLoading.value = true;
|
||||
try {
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
|
||||
const body = response.data as any;
|
||||
|
||||
const videos = Array.isArray(body?.data?.videos)
|
||||
? body.data.videos
|
||||
: Array.isArray(body?.videos)
|
||||
? body.videos
|
||||
: [];
|
||||
|
||||
recentVideos.value = videos;
|
||||
const response = await rpcClient.listVideos({ page: 1, limit: 5 });
|
||||
recentVideos.value = response.videos ?? [];
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
@@ -41,6 +52,7 @@ const fetchDashboardData = async () => {
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
refresh();
|
||||
fetchDashboardData();
|
||||
});
|
||||
</script>
|
||||
@@ -51,12 +63,12 @@ onMounted(() => {
|
||||
{ label: $t('pageHeader.dashboard') }
|
||||
]" />
|
||||
|
||||
<StatsOverview :loading="statsLoading" :stats="stats" />
|
||||
|
||||
<QuickActions :loading="recentVideosLoading" />
|
||||
|
||||
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
|
||||
|
||||
<AdminOverview v-if="isAdmin(auth.user?.role)" />
|
||||
<template v-else>
|
||||
<StatsOverview :loading="statsLoading" :stats="stats" />
|
||||
<QuickActions :loading="recentVideosLoading" />
|
||||
<RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
|
||||
</template>
|
||||
<!-- <StorageUsage :loading="loading" :stats="stats" /> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
72
src/routes/overview/components/AdminOverview.vue
Normal file
72
src/routes/overview/components/AdminOverview.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from "@/api/rpcclient";
|
||||
import { useQuery } from "@pinia/colada";
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import StatsOverview from "./StatsOverview.vue";
|
||||
|
||||
|
||||
const error = ref<string | null>(null);
|
||||
// const dashboard = ref<AdminDashboard | null>(null);
|
||||
|
||||
const cards = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ title: "Total users", value: data?.totalUsers ?? 0, note: `${data?.newUsersToday ?? 0} new today`, tone: 'accent' as const },
|
||||
{ title: "Total videos", value: data?.totalVideos ?? 0, note: `${data?.newVideosToday ?? 0} new today`, tone: 'success' as const },
|
||||
{ title: "Payments", value: data?.totalPayments ?? 0, note: "Completed finance events", tone: 'warning' as const },
|
||||
{ title: "Revenue", value: data?.totalRevenue ?? 0, note: "Lifetime gross amount", tone: 'neutral' as const },
|
||||
];
|
||||
});
|
||||
|
||||
const secondaryCards = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ title: "Active subscriptions", value: data?.activeSubscriptions ?? 0 },
|
||||
{ title: "Ad templates", value: data?.totalAdTemplates ?? 0 },
|
||||
{ title: "New users today", value: data?.newUsersToday ?? 0 },
|
||||
{ title: "New videos today", value: data?.newVideosToday ?? 0 },
|
||||
];
|
||||
});
|
||||
|
||||
const highlights = computed(() => {
|
||||
const data = dashboard.value;
|
||||
return [
|
||||
{ label: "Acquisition", value: `${data?.newUsersToday ?? 0} user signups in the current day window.` },
|
||||
{ label: "Content velocity", value: `${data?.newVideosToday ?? 0} newly created videos landed today.` },
|
||||
{ label: "Catalog depth", value: `${data?.totalAdTemplates ?? 0} ad templates available to pair with uploads.` },
|
||||
];
|
||||
});
|
||||
|
||||
const { data: dashboard, isLoading, refresh } = useQuery({
|
||||
key: () => ['admin-dashboard'],
|
||||
query: () => rpcClient.getAdminDashboard(),
|
||||
});
|
||||
onMounted(refresh);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<StatsOverview :loading="isLoading" :stats="cards" />
|
||||
<div class="mb-8">
|
||||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)]">
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<div v-for="card in secondaryCards" :key="card.title"
|
||||
class="rounded-lg border border-border bg-muted/15 px-4 py-4">
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ card.title }}</div>
|
||||
<div class="mt-3 text-2xl font-semibold tracking-tight text-foreground">{{ isLoading ? '—' : card.value }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg border border-border bg-muted/15 p-4">
|
||||
<div class="text-[11px] font-medium text-foreground/55">Operations notes</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div v-for="item in highlights" :key="item.label"
|
||||
class="rounded-2xl border border-border bg-background px-4 py-3">
|
||||
<div class="text-[11px] font-medium text-foreground/55">{{ item.label }}</div>
|
||||
<div class="mt-1 text-sm leading-6 text-foreground/70">{{ item.value }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,11 +1,10 @@
|
||||
<template>
|
||||
<div class="text-3xl font-bold text-gray-900 mb-1">
|
||||
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || t('app.name') }) }}</span>
|
||||
<span class=":uno: bg-[linear-gradient(130deg,#14a74b_0%,#22c55e_35%,#10b981_65%,#06b6d4_100%)] bg-clip-text text-transparent">{{ $t('overview.welcome.title', { name: auth.user?.username || $t('app.name') }) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import Chart from '@/components/icons/Chart.vue';
|
||||
import Credit from '@/components/icons/Credit.vue';
|
||||
import Upload from '@/components/icons/Upload.vue';
|
||||
import Video from '@/components/icons/Video.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Referral from './Referral.vue';
|
||||
|
||||
@@ -71,12 +71,12 @@ const quickActions = computed(() => [
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button v-for="action in quickActions" :key="action.title" @click="action.onClick" :class="[
|
||||
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-surface',
|
||||
'p-6 rounded-xl text-left transition-all duration-200 flex flex-col bg-header',
|
||||
'border border-gray-300 hover:border-primary hover:shadow-lg',
|
||||
'group press-animated',
|
||||
]">
|
||||
<div
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-muted group-hover:bg-primary/10">
|
||||
class="w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-muted-dark group-hover:bg-primary/10">
|
||||
<component filled :is="action.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||
|
||||
@@ -1,137 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { ModelVideo } from '@/api/client';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
|
||||
import { formatDate, formatDuration } from '@/lib/utils';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
loading: boolean;
|
||||
videos: ModelVideo[];
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const router = useRouter();
|
||||
const uiState = useUIState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<ModelVideo>[]>(() => [
|
||||
{
|
||||
id: 'video',
|
||||
header: t('overview.recentVideos.table.video'),
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-3' }, [
|
||||
h('div', { class: 'h-12 w-20 flex-shrink-0 overflow-hidden rounded bg-gray-200' }, row.original.thumbnail
|
||||
? h('img', {
|
||||
src: row.original.thumbnail,
|
||||
alt: row.original.title,
|
||||
class: 'h-full w-full object-cover',
|
||||
})
|
||||
: h('div', { class: 'flex h-full w-full items-center justify-center' }, [
|
||||
h('span', { class: 'i-heroicons-film text-xl text-gray-400' }),
|
||||
])),
|
||||
h('div', { class: 'min-w-0 flex-1' }, [
|
||||
h('p', { class: 'truncate font-medium text-gray-900' }, row.original.title),
|
||||
h('p', { class: 'truncate text-sm text-gray-500' }, row.original.description || t('overview.recentVideos.noDescription')),
|
||||
]),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('overview.recentVideos.table.status'),
|
||||
accessorFn: row => row.status || '',
|
||||
cell: ({ row }) => h('span', {
|
||||
class: ['whitespace-nowrap rounded-full px-2 py-1 text-xs font-medium', getStatusClass(row.original.status)],
|
||||
}, row.original.status || t('overview.recentVideos.unknownStatus')),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duration',
|
||||
header: t('overview.recentVideos.table.duration'),
|
||||
accessorFn: row => Number(row.duration || 0),
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatDuration(row.original.duration)),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
header: t('overview.recentVideos.table.uploadDate'),
|
||||
accessorFn: row => row.createdAt || '',
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-gray-500' }, formatDate(row.original.createdAt)),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('overview.recentVideos.table.actions'),
|
||||
enableSorting: false,
|
||||
cell: () => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-gray-100',
|
||||
title: t('overview.recentVideos.actionEdit'),
|
||||
}, [h('span', { class: 'i-heroicons-pencil h-4 w-4 text-gray-600' })]),
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-gray-100',
|
||||
title: t('overview.recentVideos.actionShare'),
|
||||
}, [h('span', { class: 'i-heroicons-share h-4 w-4 text-gray-600' })]),
|
||||
h('button', {
|
||||
class: 'rounded p-1.5 transition-colors hover:bg-red-100',
|
||||
title: t('overview.recentVideos.actionDelete'),
|
||||
}, [h('span', { class: 'i-heroicons-trash h-4 w-4 text-red-600' })]),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-xs font-medium uppercase tracking-wider text-gray-500',
|
||||
cellClass: 'px-6 py-4',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="w-32 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4">
|
||||
<div class="w-16 h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="w-[30%] h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-[20%] h-3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||
<router-link to="/videos"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1">
|
||||
{{ t('overview.recentVideos.viewAll') }}
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<EmptyState v-if="videos.length === 0" :title="t('overview.recentVideos.emptyTitle')"
|
||||
:description="t('overview.recentVideos.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png" :actionLabel="t('overview.recentVideos.emptyAction')"
|
||||
:onAction="() => uiState.toggleUploadDialog()" />
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.video') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.status') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.duration') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.uploadDate') }}</th>
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{{ t('overview.recentVideos.table.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in videos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-20 h-12 bg-gray-200 rounded 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-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">
|
||||
{{ video.description || t('overview.recentVideos.noDescription') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span
|
||||
:class="['px-2 py-1 text-xs font-medium rounded-full whitespace-nowrap', getStatusClass(video.status)]">
|
||||
{{ video.status || t('overview.recentVideos.unknownStatus') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionEdit')">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" :title="t('overview.recentVideos.actionShare')">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" :title="t('overview.recentVideos.actionDelete')">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div class="h-6 w-32 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-4 w-20 rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white">
|
||||
<div v-for="i in 5" :key="i" class="border-b border-gray-200 p-4 last:border-b-0">
|
||||
<div class="flex gap-4">
|
||||
<div class="h-10 w-16 rounded bg-gray-200 animate-pulse" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 w-[30%] rounded bg-gray-200 animate-pulse" />
|
||||
<div class="h-3 w-[20%] rounded bg-gray-200 animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h2 class="text-xl font-semibold">{{ t('overview.recentVideos.title') }}</h2>
|
||||
<router-link to="/videos" class="flex items-center gap-1 text-sm font-medium text-primary hover:underline">
|
||||
{{ t('overview.recentVideos.viewAll') }}
|
||||
<span class="i-heroicons-arrow-right h-4 w-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<EmptyState
|
||||
v-if="videos.length === 0"
|
||||
:title="t('overview.recentVideos.emptyTitle')"
|
||||
:description="t('overview.recentVideos.emptyDescription')"
|
||||
imageUrl="https://cdn-icons-png.flaticon.com/512/7486/7486747.png"
|
||||
:actionLabel="t('overview.recentVideos.emptyAction')"
|
||||
:onAction="() => uiState.toggleUploadDialog()"
|
||||
/>
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="props.videos"
|
||||
:columns="columns"
|
||||
:get-row-id="(row, index) => row.id || `recent-video-${index}`"
|
||||
wrapperClass="rounded-xl border border-gray-200 bg-white"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-gray-50 border-b border-gray-200"
|
||||
bodyRowClass="hover:bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-surface">
|
||||
<div class="rounded-xl border border-gray-300 hover:border-primary hover:shadow-lg text-card-foreground bg-header">
|
||||
<div class="flex flex-col space-y-1.5 p-6">
|
||||
<h3 class="text-lg font-semibold leading-none tracking-tight">{{ t('overview.referral.title') }}</h3>
|
||||
</div>
|
||||
@@ -27,14 +27,19 @@
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const isCopied = ref(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
const url = computed(() => `${location.origin}/ref/${auth.user?.username || ''}`);
|
||||
const url = computed(() => {
|
||||
if (typeof location === 'undefined') {
|
||||
return auth.user?.username ? `/ref/${auth.user.username}` : '';
|
||||
}
|
||||
return `${location.origin}/ref/${auth.user?.username || ''}`;
|
||||
});
|
||||
|
||||
const copyToClipboard = ($event: MouseEvent) => {
|
||||
if ($event.target instanceof HTMLInputElement) {
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import StatsCard, { type StatProps } from '@/components/dashboard/StatsCard.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
stats: {
|
||||
totalVideos: number;
|
||||
totalViews: number;
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
};
|
||||
stats: StatProps[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -21,7 +15,7 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
|
||||
<template>
|
||||
<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 3" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div v-for="i in stats.length" :key="i" class="bg-header rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
@@ -33,12 +27,6 @@ const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : '
|
||||
</div>
|
||||
|
||||
<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.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
|
||||
:trend="{ value: 8, isPositive: true }" />
|
||||
|
||||
<StatsCard :title="t('overview.stats.storageUsed')"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
|
||||
<StatsCard v-for="stat in stats" :key="stat.title" v-bind="stat"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,97 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { client } 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 AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.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 { computed, ref, watch } from 'vue';
|
||||
import AdsVastDialog from './components/AdsVastDialog.vue';
|
||||
import AdsVastNotices from './components/AdsVastNotices.vue';
|
||||
import AdsVastTable from './components/AdsVastTable.tsx';
|
||||
import AdsVastToolbar from './components/AdsVastToolbar.vue';
|
||||
import type {
|
||||
AdTemplate,
|
||||
CreateAdTemplateRequest
|
||||
} from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
interface VastTemplate {
|
||||
id: string;
|
||||
name: string;
|
||||
vastUrl: string;
|
||||
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number;
|
||||
enabled: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
type AdTemplateApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
vast_tag_url?: string;
|
||||
ad_format?: 'pre-roll' | 'mid-roll' | 'post-roll';
|
||||
duration?: number | null;
|
||||
is_active?: boolean;
|
||||
is_default?: boolean;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
const adFormatOptions = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
const createInitialFormData = (): CreateAdTemplateRequest => ({
|
||||
name: '',
|
||||
description: '',
|
||||
vastTagUrl: '',
|
||||
adFormat: 'pre-roll',
|
||||
duration: undefined,
|
||||
isActive: true,
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const editingTemplate = ref<VastTemplate | null>(null);
|
||||
const editingTemplate = ref<AdTemplate | 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({
|
||||
name: '',
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
|
||||
duration: undefined as number | undefined,
|
||||
isDefault: false,
|
||||
});
|
||||
const formData = ref<CreateAdTemplateRequest>(createInitialFormData());
|
||||
|
||||
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 response = await rpcClient.listAdTemplates();
|
||||
return response.templates || [];
|
||||
},
|
||||
});
|
||||
|
||||
const templates = computed(() => templatesSnapshot.value || []);
|
||||
const templates = computed<AdTemplate[]>(() => templatesSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
|
||||
|
||||
const refetchTemplates = () => refetch((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
const canCreateTemplate = computed(() => !isFreePlan.value && !isInitialLoading.value && !isMutating.value);
|
||||
const canEditDialog = computed(() => !isFreePlan.value && !saving.value);
|
||||
|
||||
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
|
||||
|
||||
@@ -125,13 +87,7 @@ watch(error, (value, previous) => {
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = {
|
||||
name: '',
|
||||
vastUrl: '',
|
||||
adFormat: 'pre-roll',
|
||||
duration: undefined,
|
||||
isDefault: false,
|
||||
};
|
||||
formData.value = createInitialFormData();
|
||||
editingTemplate.value = null;
|
||||
};
|
||||
|
||||
@@ -146,32 +102,40 @@ const openAddDialog = () => {
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const openEditDialog = (template: VastTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
const applyTemplateToForm = (template: AdTemplate) => {
|
||||
formData.value = {
|
||||
name: template.name,
|
||||
vastUrl: template.vastUrl,
|
||||
adFormat: template.adFormat,
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat || 'pre-roll',
|
||||
duration: template.duration,
|
||||
isDefault: template.isDefault,
|
||||
isActive: template.isActive,
|
||||
isDefault: Boolean(template.isDefault),
|
||||
};
|
||||
};
|
||||
|
||||
const openEditDialog = (template: AdTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
applyTemplateToForm(template);
|
||||
editingTemplate.value = template;
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
vast_tag_url: formData.value.vastUrl.trim(),
|
||||
ad_format: formData.value.adFormat,
|
||||
const buildRequestBody = (enabled = true): Parameters<typeof rpcClient.createAdTemplate>[0] => ({
|
||||
...formData.value,
|
||||
name: (formData.value.name || '').trim(),
|
||||
description: '',
|
||||
vastTagUrl: (formData.value.vastTagUrl || '').trim(),
|
||||
adFormat: formData.value.adFormat || 'pre-roll',
|
||||
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
|
||||
is_active: enabled,
|
||||
is_default: enabled ? formData.value.isDefault : false,
|
||||
isActive: enabled,
|
||||
isDefault: enabled ? Boolean(formData.value.isDefault) : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !ensurePaidPlan()) return;
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
if (!(formData.value.name || '').trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.nameRequiredSummary'),
|
||||
@@ -180,7 +144,7 @@ const handleSave = async () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!formData.value.vastUrl.trim()) {
|
||||
if (!(formData.value.vastTagUrl || '').trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.adsVast.toast.urlRequiredSummary'),
|
||||
@@ -190,7 +154,7 @@ const handleSave = async () => {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
new URL(formData.value.vastUrl);
|
||||
new URL(formData.value.vastTagUrl || '');
|
||||
} catch {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
@@ -213,11 +177,10 @@ const handleSave = async () => {
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingTemplate.value) {
|
||||
await client.adTemplates.adTemplatesUpdate(
|
||||
editingTemplate.value.id,
|
||||
buildRequestBody(editingTemplate.value.enabled),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: editingTemplate.value.id || '',
|
||||
...buildRequestBody(Boolean(editingTemplate.value.isActive)),
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.updatedSummary'),
|
||||
@@ -225,7 +188,7 @@ const handleSave = async () => {
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
|
||||
await rpcClient.createAdTemplate(buildRequestBody(true));
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.createdSummary'),
|
||||
@@ -234,7 +197,7 @@ const handleSave = async () => {
|
||||
});
|
||||
}
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
closeDialog();
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
@@ -244,28 +207,30 @@ const handleSave = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
const handleToggle = async (template: AdTemplate, nextValue: boolean) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
togglingId.value = template.id;
|
||||
togglingId.value = template.id || null;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id || '',
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: nextValue,
|
||||
is_default: nextValue ? template.isDefault : false,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: nextValue,
|
||||
isDefault: nextValue ? Boolean(template.isDefault) : false,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
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,
|
||||
name: template.name || '',
|
||||
state: nextValue
|
||||
? t('settings.adsVast.state.enabled')
|
||||
: t('settings.adsVast.state.disabled'),
|
||||
@@ -280,25 +245,27 @@ const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (template: VastTemplate) => {
|
||||
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
|
||||
const handleSetDefault = async (template: AdTemplate) => {
|
||||
if (Boolean(template.isDefault) || !Boolean(template.isActive) || !ensurePaidPlan()) return;
|
||||
|
||||
defaultingId.value = template.id;
|
||||
defaultingId.value = template.id || null;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesUpdate(template.id, {
|
||||
name: template.name,
|
||||
vast_tag_url: template.vastUrl,
|
||||
ad_format: template.adFormat,
|
||||
await rpcClient.updateAdTemplate({
|
||||
id: template.id || '',
|
||||
name: template.name || '',
|
||||
description: template.description || '',
|
||||
vastTagUrl: template.vastTagUrl || '',
|
||||
adFormat: template.adFormat,
|
||||
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
|
||||
is_active: template.enabled,
|
||||
is_default: true,
|
||||
}, { baseUrl: '/r' });
|
||||
isActive: template.isActive,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await refetchTemplates();
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
|
||||
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
|
||||
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name || '' }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
@@ -309,19 +276,19 @@ const handleSetDefault = async (template: VastTemplate) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (template: VastTemplate) => {
|
||||
const handleDelete = (template: AdTemplate) => {
|
||||
if (!ensurePaidPlan()) return;
|
||||
|
||||
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'),
|
||||
acceptLabel: t('settings.adsVast.confirm.deleteAccept'),
|
||||
rejectLabel: t('settings.adsVast.confirm.deleteReject'),
|
||||
accept: async () => {
|
||||
deletingId.value = template.id;
|
||||
deletingId.value = template.id || null;
|
||||
try {
|
||||
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
|
||||
await refetchTemplates();
|
||||
await rpcClient.deleteAdTemplate({ id: template.id || '' });
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.adsVast.toast.deletedSummary'),
|
||||
@@ -337,43 +304,6 @@ const handleDelete = (template: VastTemplate) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: 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('settings.adsVast.toast.copiedSummary'),
|
||||
detail: t('settings.adsVast.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const adFormatLabels = computed(() => ({
|
||||
'pre-roll': t('settings.adsVast.formats.preRoll'),
|
||||
'mid-roll': t('settings.adsVast.formats.midRoll'),
|
||||
'post-roll': t('settings.adsVast.formats.postRoll'),
|
||||
}));
|
||||
|
||||
const getAdFormatLabel = (format: string) => adFormatLabels.value[format as keyof typeof adFormatLabels.value] || format;
|
||||
|
||||
const getAdFormatColor = (format: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'pre-roll': 'bg-blue-500/10 text-blue-500',
|
||||
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
|
||||
'post-roll': 'bg-purple-500/10 text-purple-500',
|
||||
};
|
||||
return colors[format] || 'bg-gray-500/10 text-gray-500';
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -383,231 +313,36 @@ const getAdFormatColor = (format: string) => {
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :disabled="isFreePlan || isInitialLoading || isMutating" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.adsVast.createTemplate') }}
|
||||
</AppButton>
|
||||
<AdsVastToolbar :disabled="!canCreateTemplate" @create="openAddDialog" />
|
||||
</template>
|
||||
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.adsVast.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
<AdsVastNotices :is-free-plan="isFreePlan" />
|
||||
|
||||
<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>
|
||||
<AdsVastTable
|
||||
:templates="templates"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:is-read-only="isFreePlan"
|
||||
:is-mutating="isMutating"
|
||||
:saving="saving"
|
||||
:deleting-id="deletingId"
|
||||
:toggling-id="togglingId"
|
||||
:defaulting-id="defaultingId"
|
||||
@edit="openEditDialog"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggle($event.template, $event.value)"
|
||||
@set-default="handleSetDefault"
|
||||
/>
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.template') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.format') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.adsVast.table.vastUrl') }}</th>
|
||||
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.status') }}</th>
|
||||
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<template v-if="templates.length > 0">
|
||||
<tr
|
||||
v-for="template in templates"
|
||||
:key="template.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ t('settings.adsVast.defaultBadge') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/50 mt-0.5">{{ t('settings.adsVast.createdOn', { date: template.createdAt || '-' }) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<span :class="['text-xs px-2 py-1 rounded-full font-medium', getAdFormatColor(template.adFormat)]">
|
||||
{{ getAdFormatLabel(template.adFormat) }}
|
||||
</span>
|
||||
<span v-if="template.adFormat === 'mid-roll' && template.duration" class="text-xs text-foreground/50 ml-2">
|
||||
({{ template.duration }}s)
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2 max-w-[240px]">
|
||||
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
|
||||
<AppButton variant="ghost" size="sm" :disabled="isMutating" @click="copyToClipboard(template.vastUrl)">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
</AppButton>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-center">
|
||||
<AppSwitch
|
||||
:model-value="template.enabled"
|
||||
:disabled="isFreePlan || saving || deletingId !== null || defaultingId !== null || togglingId === template.id"
|
||||
@update:model-value="handleToggle(template, $event)"
|
||||
/>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<div class="flex items-center justify-end gap-2 flex-wrap">
|
||||
<span
|
||||
v-if="template.isDefault"
|
||||
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
{{ 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">
|
||||
<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-xs text-foreground/40">{{ t('settings.adsVast.emptySubtitle') }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
<AdsVastDialog
|
||||
:visible="showAddDialog"
|
||||
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
|
||||
maxWidthClass="max-w-lg"
|
||||
:editing-template="editingTemplate"
|
||||
:form-data="formData"
|
||||
:saving="saving"
|
||||
:can-edit="canEditDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@close="closeDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
|
||||
<AppInput
|
||||
id="name"
|
||||
v-model="formData.name"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
|
||||
<AppInput
|
||||
id="vastUrl"
|
||||
v-model="formData.vastUrl"
|
||||
:disabled="isFreePlan || saving"
|
||||
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="format in adFormatOptions"
|
||||
:key="format"
|
||||
type="button"
|
||||
:disabled="isFreePlan || saving"
|
||||
:class="[
|
||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
formData.adFormat === format
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-foreground/60 hover:border-primary/50'
|
||||
]"
|
||||
@click="formData.adFormat = format"
|
||||
>
|
||||
{{ getAdFormatLabel(format) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
|
||||
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
|
||||
<AppInput
|
||||
id="duration"
|
||||
v-model.number="formData.duration"
|
||||
:disabled="isFreePlan || saving"
|
||||
type="number"
|
||||
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
||||
:min="10"
|
||||
:max="600"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="closeDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="saving" :disabled="isFreePlan" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
194
src/routes/settings/AdsVast/components/AdsVastDialog.vue
Normal file
194
src/routes/settings/AdsVast/components/AdsVastDialog.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
import type { AdTemplate, CreateAdTemplateRequest } from '../types';
|
||||
|
||||
const AD_FORMAT_OPTIONS = ['pre-roll', 'mid-roll', 'post-roll'] as const;
|
||||
|
||||
type AdFormatOption = NonNullable<CreateAdTemplateRequest['adFormat']>;
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editingTemplate: AdTemplate | null;
|
||||
formData: CreateAdTemplateRequest;
|
||||
saving: boolean;
|
||||
canEdit: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'update:formData', value: CreateAdTemplateRequest): void;
|
||||
(e: 'save'): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = computed(() => props.editingTemplate
|
||||
? t('settings.adsVast.dialog.editTitle')
|
||||
: t('settings.adsVast.dialog.createTitle'));
|
||||
|
||||
const canToggleDefault = computed(() => props.canEdit && (!props.editingTemplate || Boolean(props.editingTemplate.isActive)));
|
||||
|
||||
const defaultHint = computed(() => props.editingTemplate && !Boolean(props.editingTemplate.isActive)
|
||||
? t('settings.adsVast.dialog.defaultDisabledHint')
|
||||
: t('settings.adsVast.dialog.defaultHint'));
|
||||
|
||||
const adFormatLabels = computed<Record<string, string>>(() => ({
|
||||
'pre-roll': t('settings.adsVast.formats.preRoll'),
|
||||
'mid-roll': t('settings.adsVast.formats.midRoll'),
|
||||
'post-roll': t('settings.adsVast.formats.postRoll'),
|
||||
}));
|
||||
|
||||
const updateForm = (patch: Partial<CreateAdTemplateRequest>) => {
|
||||
emit('update:formData', {
|
||||
...props.formData,
|
||||
...patch,
|
||||
});
|
||||
};
|
||||
|
||||
const updateTextField = (key: 'name' | 'vastTagUrl', value: string | number | null) => {
|
||||
updateForm({
|
||||
[key]: typeof value === 'string' ? value : value == null ? '' : String(value),
|
||||
});
|
||||
};
|
||||
|
||||
const updateDuration = (value: string | number | null) => {
|
||||
if (typeof value === 'number') {
|
||||
updateForm({ duration: value });
|
||||
return;
|
||||
}
|
||||
|
||||
if (value == null || value === '') {
|
||||
updateForm({ duration: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
updateForm({ duration: Number.isNaN(parsed) ? undefined : parsed });
|
||||
};
|
||||
|
||||
const updateCheckbox = (event: Event) => {
|
||||
updateForm({
|
||||
isDefault: (event.target as HTMLInputElement).checked,
|
||||
});
|
||||
};
|
||||
|
||||
const selectAdFormat = (format: AdFormatOption) => {
|
||||
updateForm({
|
||||
adFormat: format,
|
||||
duration: format === 'mid-roll' ? props.formData.duration : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const formatButtonClass = (format: AdFormatOption) => [
|
||||
'px-3 py-2 border rounded-md text-sm font-medium transition-all disabled:opacity-60 disabled:cursor-not-allowed',
|
||||
props.formData.adFormat === format
|
||||
? 'border-primary bg-primary/5 text-primary'
|
||||
: 'border-border text-foreground/60 hover:border-primary/50',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
:title="title"
|
||||
maxWidthClass="max-w-lg"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.templateName') }}</label>
|
||||
<AppInput
|
||||
id="name"
|
||||
:model-value="formData.name"
|
||||
:disabled="!canEdit"
|
||||
:placeholder="t('settings.adsVast.dialog.templateNamePlaceholder')"
|
||||
@update:model-value="updateTextField('name', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="vastUrl" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.vastUrlLabel') }}</label>
|
||||
<AppInput
|
||||
id="vastUrl"
|
||||
:model-value="formData.vastTagUrl"
|
||||
:disabled="!canEdit"
|
||||
:placeholder="t('settings.adsVast.dialog.vastUrlPlaceholder')"
|
||||
@update:model-value="updateTextField('vastTagUrl', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adFormat') }}</label>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="format in AD_FORMAT_OPTIONS"
|
||||
:key="format"
|
||||
type="button"
|
||||
:disabled="!canEdit"
|
||||
:class="formatButtonClass(format)"
|
||||
@click="selectAdFormat(format)"
|
||||
>
|
||||
{{ adFormatLabels[format] }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
|
||||
<label for="duration" class="text-sm font-medium text-foreground">{{ t('settings.adsVast.dialog.adInterval') }}</label>
|
||||
<AppInput
|
||||
id="duration"
|
||||
:model-value="formData.duration"
|
||||
:disabled="!canEdit"
|
||||
type="number"
|
||||
:placeholder="t('settings.adsVast.dialog.adIntervalPlaceholder')"
|
||||
:min="10"
|
||||
:max="600"
|
||||
@update:model-value="updateDuration"
|
||||
/>
|
||||
</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',
|
||||
canToggleDefault && !saving ? 'cursor-pointer' : 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
:checked="Boolean(formData.isDefault)"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canToggleDefault || saving"
|
||||
@change="updateCheckbox($event)"
|
||||
>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{{ t('settings.adsVast.dialog.defaultCheckbox') }}</p>
|
||||
<p class="mt-0.5 text-xs text-foreground/60">{{ defaultHint }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="saving" :disabled="!canEdit" @click="emit('save')">
|
||||
<template #icon>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
</template>
|
||||
{{ editingTemplate ? t('settings.adsVast.dialog.update') : t('settings.adsVast.dialog.create') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
26
src/routes/settings/AdsVast/components/AdsVastNotices.vue
Normal file
26
src/routes/settings/AdsVast/components/AdsVastNotices.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
isFreePlan: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.adsVast.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
252
src/routes/settings/AdsVast/components/AdsVastTable.tsx
Normal file
252
src/routes/settings/AdsVast/components/AdsVastTable.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
import { defineComponent, computed, type PropType } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import type { AdTemplate } from '../types';
|
||||
|
||||
// Components
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'AdTemplateTable',
|
||||
props: {
|
||||
templates: { type: Array as PropType<AdTemplate[]>, required: true },
|
||||
isInitialLoading: { type: Boolean, default: false },
|
||||
isReadOnly: { type: Boolean, default: false },
|
||||
isMutating: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false },
|
||||
deletingId: { type: String as PropType<string | null>, default: null },
|
||||
togglingId: { type: String as PropType<string | null>, default: null },
|
||||
defaultingId: { type: String as PropType<string | null>, default: null },
|
||||
},
|
||||
emits: {
|
||||
edit: (template: AdTemplate) => true,
|
||||
delete: (template: AdTemplate) => true,
|
||||
'toggle-active': (payload: { template: AdTemplate; value: boolean }) => true,
|
||||
'set-default': (template: AdTemplate) => true,
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const toast = useAppToast();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const adFormatLabels = computed<Record<string, string>>(() => ({
|
||||
'pre-roll': t('settings.adsVast.formats.preRoll'),
|
||||
'mid-roll': t('settings.adsVast.formats.midRoll'),
|
||||
'post-roll': t('settings.adsVast.formats.postRoll'),
|
||||
}));
|
||||
|
||||
const getAdFormatLabel = (format?: string) => adFormatLabels.value[format || ''] || format || '-';
|
||||
|
||||
const getAdFormatColor = (format?: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
'pre-roll': 'bg-blue-500/10 text-blue-500',
|
||||
'mid-roll': 'bg-yellow-500/10 text-yellow-500',
|
||||
'post-roll': 'bg-purple-500/10 text-purple-500',
|
||||
};
|
||||
return colors[format || ''] || 'bg-gray-500/10 text-gray-500';
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: 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('settings.adsVast.toast.copiedSummary'),
|
||||
detail: t('settings.adsVast.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
|
||||
const columns = computed<ColumnDef<AdTemplate>[]>(() => [
|
||||
{
|
||||
id: 'template',
|
||||
header: t('settings.adsVast.table.template'),
|
||||
accessorFn: (row) => row.name || '',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="text-sm font-medium text-foreground">{row.original.name || ''}</span>
|
||||
{row.original.isDefault && (
|
||||
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{t('settings.adsVast.defaultBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-foreground/50">
|
||||
{t('settings.adsVast.createdOn', { date: row.original.createdAt || '-' })}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'format',
|
||||
header: t('settings.adsVast.table.format'),
|
||||
accessorFn: (row) => row.adFormat || '',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<span class={['rounded-full px-2 py-1 text-xs font-medium', getAdFormatColor(row.original.adFormat)]}>
|
||||
{getAdFormatLabel(row.original.adFormat)}
|
||||
</span>
|
||||
{row.original.adFormat === 'mid-roll' && row.original.duration && (
|
||||
<span class="ml-2 text-xs text-foreground/50">({row.original.duration}s)</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'vastUrl',
|
||||
header: t('settings.adsVast.table.vastUrl'),
|
||||
accessorFn: (row) => row.vastTagUrl || '',
|
||||
cell: ({ row }) => (
|
||||
<div class="flex max-w-[240px] items-center gap-2">
|
||||
<code class="truncate text-xs text-foreground/60">{row.original.vastTagUrl || ''}</code>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.isMutating || !row.original.vastTagUrl}
|
||||
onClick={() => copyToClipboard(row.original.vastTagUrl || '')}
|
||||
v-slots={{
|
||||
icon: () => <CheckIcon class="h-4 w-4" />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('common.status'),
|
||||
accessorFn: (row) => Number(Boolean(row.isActive)),
|
||||
cell: ({ row }) => (
|
||||
<div class="text-center">
|
||||
<AppSwitch
|
||||
modelValue={Boolean(row.original.isActive)}
|
||||
disabled={
|
||||
props.isReadOnly ||
|
||||
props.saving ||
|
||||
props.deletingId !== null ||
|
||||
props.defaultingId !== null ||
|
||||
props.togglingId === row.original.id
|
||||
}
|
||||
onUpdate:modelValue={(value: boolean) => emit('toggle-active', { template: row.original, value })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => (
|
||||
<div class="flex flex-wrap items-center justify-end gap-2">
|
||||
{row.original.isDefault ? (
|
||||
<span class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary">
|
||||
{t('settings.adsVast.actions.default')}
|
||||
</span>
|
||||
) : (
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
loading={props.defaultingId === row.original.id}
|
||||
disabled={
|
||||
props.isReadOnly ||
|
||||
props.saving ||
|
||||
props.deletingId !== null ||
|
||||
props.togglingId !== null ||
|
||||
props.defaultingId !== null ||
|
||||
!Boolean(row.original.isActive)
|
||||
}
|
||||
onClick={() => emit('set-default', row.original)}
|
||||
>
|
||||
{t('settings.adsVast.actions.setDefault')}
|
||||
</AppButton>
|
||||
)}
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.isReadOnly || props.isMutating}
|
||||
onClick={() => emit('edit', row.original)}
|
||||
v-slots={{
|
||||
icon: () => <PencilIcon class="h-4 w-4" />
|
||||
}}
|
||||
/>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={props.isReadOnly || props.isMutating}
|
||||
onClick={() => emit('delete', row.original)}
|
||||
v-slots={{
|
||||
icon: () => <TrashIcon class="h-4 w-4 text-danger" />
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return () => (
|
||||
<>
|
||||
{props.isInitialLoading ? (
|
||||
<SettingsTableSkeleton columns={5} rows={4} />
|
||||
) : (
|
||||
<BaseTable
|
||||
data={props.templates}
|
||||
columns={columns.value}
|
||||
getRowId={(row: AdTemplate, index: number) =>
|
||||
row.id || `${row.name || 'template'}:${row.vastTagUrl || index}`
|
||||
}
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
v-slots={{
|
||||
empty: () => (
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{t('settings.adsVast.emptyTitle')}</p>
|
||||
<p class="text-xs text-foreground/40">{t('settings.adsVast.emptySubtitle')}</p>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
24
src/routes/settings/AdsVast/components/AdsVastToolbar.vue
Normal file
24
src/routes/settings/AdsVast/components/AdsVastToolbar.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppButton size="sm" :disabled="disabled" @click="emit('create')">
|
||||
<template #icon>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</template>
|
||||
{{ t('settings.adsVast.createTemplate') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
6
src/routes/settings/AdsVast/types.ts
Normal file
6
src/routes/settings/AdsVast/types.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type { AdTemplate } from '@/server/gen/proto/app/v1/common';
|
||||
export type {
|
||||
CreateAdTemplateRequest,
|
||||
DeleteAdTemplateRequest,
|
||||
UpdateAdTemplateRequest,
|
||||
} from '@/server/gen/proto/app/v1/catalog';
|
||||
@@ -1,16 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { useUsageQuery } from '@/composables/useUsageQuery';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import BillingHistorySection from '@/routes/settings/components/billing/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/components/billing/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/components/billing/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/components/billing/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/components/billing/BillingWalletRow.vue';
|
||||
import BillingHistorySection from '@/routes/settings/Billing/components/BillingHistorySection.vue';
|
||||
import BillingPlansSection from '@/routes/settings/Billing/components/BillingPlansSection.vue';
|
||||
import BillingTopupDialog from '@/routes/settings/Billing/components/BillingTopupDialog.vue';
|
||||
import BillingUsageSection from '@/routes/settings/Billing/components/BillingUsageSection.vue';
|
||||
import BillingWalletRow from '@/routes/settings/Billing/components/BillingWalletRow.vue';
|
||||
import type { Plan as ModelPlan, PaymentHistoryItem as PaymentHistoryApiItem } from '@/server/gen/proto/app/v1/common';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
@@ -19,30 +21,10 @@ import { computed, ref, watch } from 'vue';
|
||||
const TERM_OPTIONS = [1, 3, 6, 12] as const;
|
||||
type UpgradePaymentMethod = 'wallet' | 'topup';
|
||||
|
||||
type PlansEnvelope = {
|
||||
data?: {
|
||||
plans?: ModelPlan[];
|
||||
} | ModelPlan[];
|
||||
};
|
||||
|
||||
type PaymentHistoryApiItem = {
|
||||
id?: string;
|
||||
amount?: number;
|
||||
currency?: string;
|
||||
status?: string;
|
||||
plan_name?: string;
|
||||
invoice_id?: string;
|
||||
kind?: string;
|
||||
term_months?: number;
|
||||
payment_method?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryEnvelope = {
|
||||
data?: {
|
||||
payments?: PaymentHistoryApiItem[];
|
||||
};
|
||||
type InvoiceDownloadResponse = {
|
||||
filename?: string;
|
||||
contentType?: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type PaymentHistoryItem = {
|
||||
@@ -69,7 +51,7 @@ const { t, i18next } = useTranslation();
|
||||
|
||||
const { data: plansResponse, isLoading } = useQuery({
|
||||
key: () => ['billing-plans'],
|
||||
query: () => client.plans.plansList({ baseUrl: '/r' }),
|
||||
query: () => rpcClient.listPlans(),
|
||||
});
|
||||
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
|
||||
|
||||
@@ -89,17 +71,7 @@ const purchaseTopupAmount = ref<number | null>(null);
|
||||
const purchaseLoading = ref(false);
|
||||
const purchaseError = ref<string | null>(null);
|
||||
|
||||
const plans = computed(() => {
|
||||
const body = plansResponse.value?.data as PlansEnvelope | undefined;
|
||||
const payload = body?.data;
|
||||
|
||||
if (Array.isArray(payload)) return payload;
|
||||
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
|
||||
return payload.plans;
|
||||
}
|
||||
|
||||
return [] as ModelPlan[];
|
||||
});
|
||||
const plans = computed(() => plansResponse.value?.plans || [] as ModelPlan[]);
|
||||
|
||||
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
|
||||
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
|
||||
@@ -109,11 +81,11 @@ const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
|
||||
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
|
||||
const storageLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.storage_limit || 10737418240;
|
||||
return activePlan?.storageLimit || 10737418240;
|
||||
});
|
||||
const uploadsLimit = computed(() => {
|
||||
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
|
||||
return activePlan?.upload_limit || 50;
|
||||
return activePlan?.uploadLimit || 50;
|
||||
});
|
||||
const storagePercentage = computed(() =>
|
||||
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
|
||||
@@ -155,15 +127,6 @@ const upgradeSubmitLabel = computed(() => {
|
||||
|
||||
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
|
||||
|
||||
const formatBytes = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
||||
return `${new Intl.NumberFormat(localeTag.value).format(value)} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return t('settings.billing.durationMinutes', { minutes: 0 });
|
||||
if (seconds < 0) return t('settings.billing.durationMinutes', { minutes: -1 }).replace("-1", "∞")
|
||||
@@ -189,9 +152,9 @@ const formatPaymentMethodLabel = (value?: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
|
||||
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storageLimit || 0) });
|
||||
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.durationLimit) });
|
||||
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.uploadLimit || 0 });
|
||||
|
||||
const getStatusStyles = (status: string) => {
|
||||
switch (status) {
|
||||
@@ -254,25 +217,25 @@ const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || n
|
||||
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const details: string[] = [];
|
||||
|
||||
if (item.kind !== 'wallet_topup' && item.term_months) {
|
||||
details.push(formatTermLabel(item.term_months));
|
||||
if (item.kind !== 'wallet_topup' && item.termMonths) {
|
||||
details.push(formatTermLabel(item.termMonths));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.payment_method) {
|
||||
details.push(formatPaymentMethodLabel(item.payment_method));
|
||||
if (item.kind !== 'wallet_topup' && item.paymentMethod) {
|
||||
details.push(formatPaymentMethodLabel(item.paymentMethod));
|
||||
}
|
||||
if (item.kind !== 'wallet_topup' && item.expires_at) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
|
||||
if (item.kind !== 'wallet_topup' && item.expiresAt) {
|
||||
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expiresAt) }));
|
||||
}
|
||||
|
||||
return {
|
||||
id: item.id || '',
|
||||
date: formatHistoryDate(item.created_at),
|
||||
date: formatHistoryDate(item.createdAt),
|
||||
amount: item.amount || 0,
|
||||
plan: item.kind === 'wallet_topup'
|
||||
? t('settings.billing.walletTopup')
|
||||
: (item.plan_name || t('settings.billing.unknownPlan')),
|
||||
: (item.planName || t('settings.billing.unknownPlan')),
|
||||
status: normalizeHistoryStatus(item.status),
|
||||
invoiceId: item.invoice_id || '-',
|
||||
invoiceId: item.invoiceId || '-',
|
||||
currency: item.currency || 'USD',
|
||||
kind: item.kind || 'subscription',
|
||||
details,
|
||||
@@ -282,9 +245,8 @@ const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
|
||||
const loadPaymentHistory = async () => {
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
const response = await client.payments.historyList({ baseUrl: '/r' });
|
||||
const body = response.data as PaymentHistoryEnvelope | undefined;
|
||||
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
|
||||
const response = await rpcClient.listPaymentHistory();
|
||||
paymentHistory.value = (response.payments || []).map(mapHistoryItem);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
paymentHistory.value = [];
|
||||
@@ -293,22 +255,19 @@ const loadPaymentHistory = async () => {
|
||||
}
|
||||
};
|
||||
|
||||
const refetchUsageSnapshot = () => refetchUsage((fetchError) => {
|
||||
throw fetchError;
|
||||
});
|
||||
|
||||
const refreshBillingState = async () => {
|
||||
await Promise.allSettled([
|
||||
auth.fetchMe(),
|
||||
loadPaymentHistory(),
|
||||
refetchUsageSnapshot(),
|
||||
refetchUsage(),
|
||||
]);
|
||||
};
|
||||
|
||||
void loadPaymentHistory();
|
||||
|
||||
const subscriptionSummary = computed(() => {
|
||||
const expiresAt = auth.user?.plan_expires_at;
|
||||
const expiresAt = auth.user?.planExpiresAt || auth.user?.plan_expires_at;
|
||||
const formattedDate = formatHistoryDate(expiresAt);
|
||||
|
||||
if (auth.user?.plan_id) {
|
||||
@@ -433,17 +392,17 @@ const submitUpgrade = async () => {
|
||||
|
||||
try {
|
||||
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
|
||||
const payload: Record<string, any> = {
|
||||
plan_id: selectedPlan.value.id,
|
||||
term_months: selectedTermMonths.value,
|
||||
payment_method: paymentMethod,
|
||||
const payload: Parameters<typeof rpcClient.createPayment>[0] = {
|
||||
planId: selectedPlan.value.id,
|
||||
termMonths: selectedTermMonths.value,
|
||||
paymentMethod: paymentMethod,
|
||||
};
|
||||
|
||||
if (paymentMethod === 'topup') {
|
||||
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
payload.topupAmount = purchaseTopupAmount.value || selectedShortfall.value;
|
||||
}
|
||||
|
||||
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
|
||||
await rpcClient.createPayment(payload);
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -481,7 +440,7 @@ const submitUpgrade = async () => {
|
||||
const handleTopup = async (amount: number) => {
|
||||
topupLoading.value = true;
|
||||
try {
|
||||
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
|
||||
await rpcClient.topupWallet({ amount });
|
||||
await refreshBillingState();
|
||||
|
||||
toast.add({
|
||||
@@ -517,13 +476,15 @@ const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
|
||||
const content = typeof response.data === 'string' ? response.data : '';
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
|
||||
const response = await rpcClient.downloadInvoice({ id: item.id }) as InvoiceDownloadResponse;
|
||||
const content = response.content || '';
|
||||
const contentType = response.contentType || 'text/plain;charset=utf-8';
|
||||
const filename = response.filename || `${item.invoiceId}.txt`;
|
||||
const blob = new Blob([content], { type: contentType });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = `${item.invoiceId}.txt`;
|
||||
anchor.download = filename;
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
@@ -662,7 +623,7 @@ const selectPreset = (amount: number) => {
|
||||
'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',
|
||||
: 'border-border bg-header text-foreground hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectedTermMonths = months"
|
||||
>
|
||||
@@ -673,11 +634,11 @@ const selectPreset = (amount: number) => {
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-3">
|
||||
<div class="rounded-lg border border-border bg-surface p-4">
|
||||
<div class="rounded-lg border border-border bg-header 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">
|
||||
<div class="rounded-lg border border-border bg-header 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>
|
||||
@@ -707,7 +668,7 @@ const selectPreset = (amount: number) => {
|
||||
'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',
|
||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectUpgradePaymentMethod('wallet')"
|
||||
>
|
||||
@@ -723,7 +684,7 @@ const selectPreset = (amount: number) => {
|
||||
'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',
|
||||
: 'border-border bg-header hover:border-primary/30 hover:bg-muted/30',
|
||||
]"
|
||||
@click="selectUpgradePaymentMethod('topup')"
|
||||
>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelPlan } from '@/api/client';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import type { Plan as ModelPlan } from '@/server/gen/proto/app/v1/common';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -44,7 +44,7 @@ const emit = defineEmits<{
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
v-for="plan in plans.sort((a,b) => a.price - b.price)"
|
||||
v-for="plan in plans.sort((a,b) => (a.price || 0) - (b.price || 0))"
|
||||
:key="plan.id"
|
||||
:class="[
|
||||
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
|
||||
@@ -69,18 +69,6 @@ const emit = defineEmits<{
|
||||
<span class="text-foreground/60 text-sm"> / {{ $t('settings.billing.cycle.'+plan.cycle) }}</span>
|
||||
</div>
|
||||
<ul class="space-y-2 mb-4 text-sm">
|
||||
<!-- <li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanStorageText(plan) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanDurationText(plan) }}
|
||||
</li>
|
||||
<li class="flex items-center gap-2 text-foreground/70">
|
||||
<CheckIcon class="w-4 h-4 text-success shrink-0" />
|
||||
{{ getPlanUploadsText(plan) }}
|
||||
</li> -->
|
||||
<li
|
||||
v-for="feature in plan.features || []"
|
||||
:key="feature"
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AppInput from '@/components/app/AppInput.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
|
||||
defineProps<{
|
||||
@@ -16,7 +16,6 @@ defineProps<{
|
||||
hint: string;
|
||||
cancelLabel: string;
|
||||
proceedLabel: string;
|
||||
formatMoney: (amount: number) => string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -44,14 +43,14 @@ const emit = defineEmits<{
|
||||
v-for="preset in presets"
|
||||
:key="preset"
|
||||
:class="[
|
||||
'py-2 px-3 rounded-md text-sm font-medium transition-all',
|
||||
'py-2 px-3 rounded-md bg-header text-sm font-medium transition-all hover:bg-gray-500',
|
||||
amount === preset
|
||||
? 'bg-primary text-primary-foreground'
|
||||
? 'bg-primary text-white'
|
||||
: 'bg-muted/50 text-foreground hover:bg-muted'
|
||||
]"
|
||||
@click="emit('selectPreset', preset)"
|
||||
>
|
||||
{{ formatMoney(preset) }}
|
||||
${{ preset }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import SettingsRow from '../SettingsRow.vue';
|
||||
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
|
||||
|
||||
defineProps<{
|
||||
title: string;
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
|
||||
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
@@ -32,7 +32,7 @@ const handleDeleteAccount = () => {
|
||||
accept: async () => {
|
||||
deletingAccount.value = true;
|
||||
try {
|
||||
await client.me.deleteMe({ baseUrl: '/r' });
|
||||
await rpcClient.deleteMe();
|
||||
|
||||
auth.$reset();
|
||||
toast.add({
|
||||
@@ -66,7 +66,7 @@ const handleClearData = () => {
|
||||
accept: async () => {
|
||||
clearingData.value = true;
|
||||
try {
|
||||
await client.me.clearDataCreate({ baseUrl: '/r' });
|
||||
await rpcClient.clearMyData();
|
||||
|
||||
await auth.fetchMe();
|
||||
toast.add({
|
||||
|
||||
@@ -1,353 +1,218 @@
|
||||
<script setup lang="ts">
|
||||
import { client } 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 CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.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 { computed, ref, watch } from 'vue';
|
||||
import DomainsDnsDialog from './components/DomainsDnsDialog.vue';
|
||||
import DomainsDnsEmbedCode from './components/DomainsDnsEmbedCode.vue';
|
||||
import DomainsDnsNotices from './components/DomainsDnsNotices.vue';
|
||||
import DomainsDnsTable from './components/DomainsDnsTable.vue';
|
||||
import DomainsDnsToolbar from './components/DomainsDnsToolbar.vue';
|
||||
import { mapDomainItem, normalizeDomainInput } from './helpers';
|
||||
import type { DomainItem } from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const { t } = useTranslation();
|
||||
|
||||
type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
|
||||
const newDomain = ref('');
|
||||
const showAddDialog = ref(false);
|
||||
const adding = ref(false);
|
||||
const removingId = ref<string | null>(null);
|
||||
|
||||
const normalizeDomainInput = (value: string) => value
|
||||
.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);
|
||||
},
|
||||
key: () => ['settings', 'domains'],
|
||||
query: async () => {
|
||||
const response = await rpcClient.listDomains();
|
||||
return (response.domains || []).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;
|
||||
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,
|
||||
});
|
||||
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;
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const closeAddDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
showAddDialog.value = false;
|
||||
newDomain.value = '';
|
||||
};
|
||||
|
||||
const handleAddDomain = async () => {
|
||||
if (adding.value) return;
|
||||
if (adding.value) return;
|
||||
|
||||
const domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await client.domains.domainsCreate({
|
||||
name: domainName,
|
||||
}, { baseUrl: '/r' });
|
||||
|
||||
await refetchDomains();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
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 domainName = normalizeDomainInput(newDomain.value);
|
||||
if (!domainName || !domainName.includes('.') || /[/\s]/.test(domainName)) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.invalidSummary'),
|
||||
detail: t('settings.domainsDns.toast.invalidDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const exists = domains.value.some(domain => domain.name === domainName);
|
||||
if (exists) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.domainsDns.toast.duplicateSummary'),
|
||||
detail: t('settings.domainsDns.toast.duplicateDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
adding.value = true;
|
||||
try {
|
||||
await rpcClient.createDomain({
|
||||
name: domainName,
|
||||
});
|
||||
|
||||
await refetch();
|
||||
closeAddDialog();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.addedSummary'),
|
||||
detail: t('settings.domainsDns.toast.addedDetail', { domain: domainName }),
|
||||
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: DomainItem) => {
|
||||
confirm.require({
|
||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
confirm.require({
|
||||
message: t('settings.domainsDns.confirm.removeMessage', { domain: domain.name }),
|
||||
header: t('settings.domainsDns.confirm.removeHeader'),
|
||||
acceptLabel: t('settings.domainsDns.confirm.removeAccept'),
|
||||
rejectLabel: t('settings.domainsDns.confirm.removeReject'),
|
||||
accept: async () => {
|
||||
removingId.value = domain.id;
|
||||
try {
|
||||
await rpcClient.deleteDomain({ id: domain.id });
|
||||
await refetch();
|
||||
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;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
detail: t('settings.domainsDns.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.domainsDns.toast.copiedSummary'),
|
||||
detail: t('settings.domainsDns.toast.copiedDetail'),
|
||||
life: 2000,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.domains.title')"
|
||||
:description="t('settings.content.domains.subtitle')"
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="adding" :disabled="isInitialLoading || removingId !== null" @click="openAddDialog">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.domains.title')"
|
||||
:description="t('settings.content.domains.subtitle')"
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<DomainsDnsToolbar
|
||||
:loading="adding"
|
||||
:disabled="isInitialLoading || removingId !== null"
|
||||
@create="openAddDialog"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
<DomainsDnsNotices />
|
||||
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
<DomainsDnsTable
|
||||
:domains="domains"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:adding="adding"
|
||||
:removing-id="removingId"
|
||||
@remove="handleRemoveDomain"
|
||||
/>
|
||||
|
||||
<div v-else class="border-b border-border mt-4">
|
||||
<table class="w-full">
|
||||
<thead class="bg-muted/30">
|
||||
<tr>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.domain') }}</th>
|
||||
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('settings.domainsDns.table.addedDate') }}</th>
|
||||
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">{{ t('common.actions') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-border">
|
||||
<template v-if="domains.length > 0">
|
||||
<tr
|
||||
v-for="domain in domains"
|
||||
:key="domain.id"
|
||||
class="hover:bg-muted/30 transition-all"
|
||||
>
|
||||
<td class="px-6 py-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<LinkIcon class="w-4 h-4 text-foreground/40" />
|
||||
<span class="text-sm font-medium text-foreground">{{ domain.name }}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-3 text-sm text-foreground/60">{{ domain.addedAt }}</td>
|
||||
<td class="px-6 py-3 text-right">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
:disabled="adding || removingId !== null"
|
||||
@click="handleRemoveDomain(domain)"
|
||||
>
|
||||
<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">
|
||||
<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-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<DomainsDnsEmbedCode :code="iframeCode" @copy="copyIframeCode" />
|
||||
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
|
||||
<AppButton variant="secondary" size="sm" @click="copyIframeCode">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.copyCode') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
<p class="text-xs text-foreground/60 mb-2">
|
||||
{{ t('settings.domainsDns.embedCodeHint') }}
|
||||
</p>
|
||||
<pre class="bg-surface border border-border rounded-md p-3 text-xs text-foreground/70 overflow-x-auto"><code>{{ iframeCode }}</code></pre>
|
||||
</div>
|
||||
|
||||
<AppDialog
|
||||
:visible="showAddDialog"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@close="closeAddDialog"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
|
||||
<AppInput
|
||||
id="domain"
|
||||
v-model="newDomain"
|
||||
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
|
||||
@enter="handleAddDomain"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
|
||||
</div>
|
||||
|
||||
<SettingsNotice
|
||||
tone="warning"
|
||||
:title="t('settings.domainsDns.dialog.importantTitle')"
|
||||
class="p-3"
|
||||
>
|
||||
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
|
||||
</SettingsNotice>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="closeAddDialog">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="adding" @click="handleAddDomain">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</SettingsSectionCard>
|
||||
<DomainsDnsDialog
|
||||
:visible="showAddDialog"
|
||||
:domain="newDomain"
|
||||
:adding="adding"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:domain="newDomain = $event"
|
||||
@submit="handleAddDomain"
|
||||
@close="closeAddDialog"
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
domain: string;
|
||||
adding: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'update:domain', value: string): void;
|
||||
(e: 'submit'): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
:title="t('settings.domainsDns.dialog.title')"
|
||||
maxWidthClass="max-w-md"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="domain" class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.dialog.domainLabel') }}</label>
|
||||
<AppInput
|
||||
id="domain"
|
||||
:model-value="domain"
|
||||
:placeholder="t('settings.domainsDns.dialog.domainPlaceholder')"
|
||||
@update:model-value="emit('update:domain', String($event ?? ''))"
|
||||
@enter="emit('submit')"
|
||||
/>
|
||||
<p class="text-xs text-foreground/50">{{ t('settings.domainsDns.dialog.domainHint') }}</p>
|
||||
</div>
|
||||
|
||||
<SettingsNotice
|
||||
tone="warning"
|
||||
:title="t('settings.domainsDns.dialog.importantTitle')"
|
||||
class="p-3"
|
||||
>
|
||||
<p>{{ t('settings.domainsDns.dialog.importantDetail') }}</p>
|
||||
</SettingsNotice>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="adding" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="adding" @click="emit('submit')">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
code: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'copy'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="px-6 py-4 bg-muted/30">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-medium text-foreground">{{ t('settings.domainsDns.embedCodeTitle') }}</h4>
|
||||
<AppButton variant="secondary" size="sm" @click="emit('copy')">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.copyCode') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
|
||||
<p class="mb-2 text-xs text-foreground/60">
|
||||
{{ t('settings.domainsDns.embedCodeHint') }}
|
||||
</p>
|
||||
|
||||
<pre class="overflow-x-auto rounded-md border border-border bg-header p-3 text-xs text-foreground/70"><code>{{ code }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.domainsDns.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, h } from 'vue';
|
||||
import type { DomainItem } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
domains: DomainItem[];
|
||||
isInitialLoading: boolean;
|
||||
adding: boolean;
|
||||
removingId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'remove', domain: DomainItem): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = computed<ColumnDef<DomainItem>[]>(() => [
|
||||
{
|
||||
id: 'domain',
|
||||
header: t('settings.domainsDns.table.domain'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', { class: 'flex items-center gap-2' }, [
|
||||
h(LinkIcon, { class: 'h-4 w-4 text-foreground/40' }),
|
||||
h('span', { class: 'text-sm font-medium text-foreground' }, row.original.name),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'addedAt',
|
||||
header: t('settings.domainsDns.table.addedDate'),
|
||||
accessorFn: row => row.addedAt,
|
||||
cell: ({ row }) => h('span', { class: 'text-sm text-foreground/60' }, row.original.addedAt),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: props.adding || props.removingId !== null,
|
||||
onClick: () => emit('remove', row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-right text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="domains"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.domainsDns.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.domainsDns.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
disabled: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppButton size="sm" :loading="loading" :disabled="disabled" @click="emit('create')">
|
||||
<template #icon>
|
||||
<PlusIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('settings.domainsDns.addDomain') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
25
src/routes/settings/DomainsDns/helpers.ts
Normal file
25
src/routes/settings/DomainsDns/helpers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { DomainApiItem, DomainItem } from './types';
|
||||
|
||||
export const normalizeDomainInput = (value: string) => value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^https?:\/\//, '')
|
||||
.replace(/^www\./, '')
|
||||
.replace(/\/$/, '');
|
||||
|
||||
export 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];
|
||||
};
|
||||
|
||||
export const mapDomainItem = (item: DomainApiItem): DomainItem => ({
|
||||
id: item.id || `${item.name || 'domain'}:${item.created_at || ''}`,
|
||||
name: item.name || '',
|
||||
addedAt: formatDate(item.created_at),
|
||||
});
|
||||
11
src/routes/settings/DomainsDns/types.ts
Normal file
11
src/routes/settings/DomainsDns/types.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export type DomainApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
created_at?: string;
|
||||
};
|
||||
|
||||
export type DomainItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
addedAt: string;
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BellIcon from '@/components/icons/BellIcon.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import MailIcon from '@/components/icons/MailIcon.vue';
|
||||
@@ -65,10 +65,6 @@ 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);
|
||||
@@ -90,11 +86,10 @@ const handleSave = async () => {
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
await rpcClient.updatePreferences(
|
||||
toNotificationPreferencesPayload(notificationSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetchPreferences();
|
||||
await refetch();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
|
||||
402
src/routes/settings/PlayerConfigs/PlayerConfigs.vue
Normal file
402
src/routes/settings/PlayerConfigs/PlayerConfigs.vue
Normal file
@@ -0,0 +1,402 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useQuery } from '@pinia/colada';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import PlayerConfigDialog from './components/PlayerConfigDialog.vue';
|
||||
import PlayerConfigsNotices from './components/PlayerConfigsNotices.vue';
|
||||
import PlayerConfigsTable from './components/PlayerConfigsTable.vue';
|
||||
import PlayerConfigsToolbar from './components/PlayerConfigsToolbar.vue';
|
||||
import type { PlayerConfig, PlayerConfigApiItem, PlayerConfigFormData } from './types';
|
||||
|
||||
const toast = useAppToast();
|
||||
const confirm = useAppConfirm();
|
||||
const auth = useAuthStore();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const createInitialFormData = (): PlayerConfigFormData => ({
|
||||
name: '',
|
||||
description: '',
|
||||
autoplay: false,
|
||||
loop: false,
|
||||
muted: false,
|
||||
showControls: true,
|
||||
pip: true,
|
||||
airplay: true,
|
||||
chromecast: true,
|
||||
encrytionM3u8: true,
|
||||
logoUrl: '',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
const showAddDialog = ref(false);
|
||||
const editingConfig = ref<PlayerConfig | 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<PlayerConfigFormData>(createInitialFormData());
|
||||
|
||||
const FREE_PLAN_LIMIT_MESSAGE = 'Free plan supports only 1 player config';
|
||||
const FREE_PLAN_RECONCILIATION_MESSAGE = 'Delete extra player configs to continue managing player configs on the free plan';
|
||||
|
||||
const isFreePlan = computed(() => !auth.user?.plan_id);
|
||||
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
|
||||
|
||||
const mapConfig = (item: PlayerConfigApiItem): PlayerConfig => ({
|
||||
id: item.id || `${item.name || 'config'}:${item.createdAt || ''}`,
|
||||
name: item.name || '',
|
||||
description: item.description || undefined,
|
||||
autoplay: Boolean(item.autoplay),
|
||||
loop: Boolean(item.loop),
|
||||
muted: Boolean(item.muted),
|
||||
showControls: item.showControls !== false,
|
||||
pip: item.pip !== false,
|
||||
airplay: item.airplay !== false,
|
||||
chromecast: item.chromecast !== false,
|
||||
encrytionM3u8: item.encrytionM3u8 !== false,
|
||||
logoUrl: item.logoUrl || undefined,
|
||||
isActive: item.isActive !== false,
|
||||
isDefault: Boolean(item.isDefault),
|
||||
createdAt: item.createdAt || '',
|
||||
});
|
||||
|
||||
const { data: configsSnapshot, error, isPending, refetch } = useQuery({
|
||||
key: () => ['settings', 'player-configs'],
|
||||
query: async () => {
|
||||
const response = await rpcClient.listPlayerConfigs();
|
||||
return (response.configs || []).map(mapConfig);
|
||||
},
|
||||
});
|
||||
|
||||
const configs = computed(() => configsSnapshot.value || []);
|
||||
const isInitialLoading = computed(() => isPending.value && !configsSnapshot.value);
|
||||
const configCount = computed(() => configs.value.length);
|
||||
const hasExactlyOneConfig = computed(() => configCount.value === 1);
|
||||
const isFreeReconciliationMode = computed(() => isFreePlan.value && configCount.value > 1);
|
||||
const canCreateConfig = computed(() => !isInitialLoading.value && !isMutating.value && (!isFreePlan.value || configCount.value === 0));
|
||||
const canManageExistingConfig = computed(() => !isMutating.value && (!isFreePlan.value || hasExactlyOneConfig.value));
|
||||
const canDeleteConfig = computed(() => !isMutating.value);
|
||||
const canEditDialog = computed(() => !saving.value && (!isFreePlan.value || hasExactlyOneConfig.value));
|
||||
const canSubmitDialog = computed(() => editingConfig.value ? canManageExistingConfig.value : canCreateConfig.value);
|
||||
|
||||
// const refetchConfigs = () => refetch((fetchError) => {
|
||||
// throw fetchError;
|
||||
// });
|
||||
|
||||
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
|
||||
|
||||
const showQuotaToast = (key: 'limit' | 'reconciliation') => {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: t(`settings.playerConfigs.toast.${key}Summary`),
|
||||
detail: t(`settings.playerConfigs.toast.${key}Detail`),
|
||||
life: 4000,
|
||||
});
|
||||
};
|
||||
|
||||
const showActionErrorToast = (value: any) => {
|
||||
const message = getErrorMessage(value, t('settings.playerConfigs.toast.failedDetail'));
|
||||
if (message === FREE_PLAN_LIMIT_MESSAGE) {
|
||||
showQuotaToast('limit');
|
||||
return;
|
||||
}
|
||||
if (message === FREE_PLAN_RECONCILIATION_MESSAGE) {
|
||||
showQuotaToast('reconciliation');
|
||||
return;
|
||||
}
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerConfigs.toast.failedSummary'),
|
||||
detail: message,
|
||||
life: 5000,
|
||||
});
|
||||
};
|
||||
|
||||
const ensureCanCreateConfig = () => {
|
||||
if (canCreateConfig.value) return true;
|
||||
if (isFreePlan.value && configCount.value >= 1) {
|
||||
showQuotaToast('limit');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const ensureCanManageExistingConfig = () => {
|
||||
if (canManageExistingConfig.value) return true;
|
||||
if (isFreeReconciliationMode.value) {
|
||||
showQuotaToast('reconciliation');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
watch(error, (value, previous) => {
|
||||
if (!value || value === previous || isMutating.value) return;
|
||||
showActionErrorToast(value);
|
||||
});
|
||||
|
||||
const resetForm = () => {
|
||||
formData.value = createInitialFormData();
|
||||
editingConfig.value = null;
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
showAddDialog.value = false;
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const openAddDialog = () => {
|
||||
if (!ensureCanCreateConfig()) return;
|
||||
resetForm();
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const applyConfigToForm = (config: PlayerConfig) => {
|
||||
formData.value = {
|
||||
name: config.name,
|
||||
description: config.description || '',
|
||||
autoplay: config.autoplay,
|
||||
loop: config.loop,
|
||||
muted: config.muted,
|
||||
showControls: config.showControls,
|
||||
pip: config.pip,
|
||||
airplay: config.airplay,
|
||||
chromecast: config.chromecast,
|
||||
encrytionM3u8: config.encrytionM3u8,
|
||||
logoUrl: config.logoUrl || '',
|
||||
isDefault: config.isDefault,
|
||||
};
|
||||
};
|
||||
|
||||
const openEditDialog = (config: PlayerConfig) => {
|
||||
if (!ensureCanManageExistingConfig()) return;
|
||||
applyConfigToForm(config);
|
||||
editingConfig.value = config;
|
||||
showAddDialog.value = true;
|
||||
};
|
||||
|
||||
const buildRequestBody = (enabled = true) => ({
|
||||
name: formData.value.name.trim(),
|
||||
description: formData.value.description.trim() || undefined,
|
||||
autoplay: formData.value.autoplay,
|
||||
loop: formData.value.loop,
|
||||
muted: formData.value.muted,
|
||||
showControls: formData.value.showControls,
|
||||
pip: formData.value.pip,
|
||||
airplay: formData.value.airplay,
|
||||
chromecast: formData.value.chromecast,
|
||||
encrytionM3u8: formData.value.encrytionM3u8,
|
||||
logoUrl: formData.value.logoUrl.trim() || undefined,
|
||||
isActive: enabled,
|
||||
isDefault: enabled ? formData.value.isDefault : false,
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value) return;
|
||||
if (editingConfig.value) {
|
||||
if (!ensureCanManageExistingConfig()) return;
|
||||
} else if (!ensureCanCreateConfig()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.value.name.trim()) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerConfigs.toast.nameRequiredSummary'),
|
||||
detail: t('settings.playerConfigs.toast.nameRequiredDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
if (editingConfig.value) {
|
||||
await rpcClient.updatePlayerConfig({
|
||||
id: editingConfig.value.id,
|
||||
...buildRequestBody(editingConfig.value.isActive),
|
||||
});
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerConfigs.toast.updatedSummary'),
|
||||
detail: t('settings.playerConfigs.toast.updatedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} else {
|
||||
await rpcClient.createPlayerConfig(buildRequestBody(true));
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerConfigs.toast.createdSummary'),
|
||||
detail: t('settings.playerConfigs.toast.createdDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
}
|
||||
|
||||
await refetch();
|
||||
closeDialog();
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggle = async (config: PlayerConfig, nextValue: boolean) => {
|
||||
if (!ensureCanManageExistingConfig()) return;
|
||||
|
||||
togglingId.value = config.id;
|
||||
try {
|
||||
await rpcClient.updatePlayerConfig({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
autoplay: config.autoplay,
|
||||
loop: config.loop,
|
||||
muted: config.muted,
|
||||
showControls: config.showControls,
|
||||
pip: config.pip,
|
||||
airplay: config.airplay,
|
||||
chromecast: config.chromecast,
|
||||
encrytionM3u8: config.encrytionM3u8,
|
||||
logoUrl: config.logoUrl,
|
||||
isActive: nextValue,
|
||||
isDefault: nextValue ? config.isDefault : false,
|
||||
});
|
||||
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: nextValue
|
||||
? t('settings.playerConfigs.toast.enabledSummary')
|
||||
: t('settings.playerConfigs.toast.disabledSummary'),
|
||||
detail: t('settings.playerConfigs.toast.toggleDetail', {
|
||||
name: config.name,
|
||||
state: nextValue
|
||||
? t('settings.playerConfigs.state.enabled')
|
||||
: t('settings.playerConfigs.state.disabled'),
|
||||
}),
|
||||
life: 2000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
togglingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSetDefault = async (config: PlayerConfig) => {
|
||||
if (config.isDefault || !config.isActive || !ensureCanManageExistingConfig()) return;
|
||||
|
||||
defaultingId.value = config.id;
|
||||
try {
|
||||
await rpcClient.updatePlayerConfig({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
autoplay: config.autoplay,
|
||||
loop: config.loop,
|
||||
muted: config.muted,
|
||||
showControls: config.showControls,
|
||||
pip: config.pip,
|
||||
airplay: config.airplay,
|
||||
chromecast: config.chromecast,
|
||||
encrytionM3u8: config.encrytionM3u8,
|
||||
logoUrl: config.logoUrl,
|
||||
isActive: config.isActive,
|
||||
isDefault: true,
|
||||
});
|
||||
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerConfigs.toast.defaultUpdatedSummary'),
|
||||
detail: t('settings.playerConfigs.toast.defaultUpdatedDetail', { name: config.name }),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
defaultingId.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = (config: PlayerConfig) => {
|
||||
if (!canDeleteConfig.value) return;
|
||||
|
||||
confirm.require({
|
||||
message: t('settings.playerConfigs.confirm.deleteMessage', { name: config.name }),
|
||||
header: t('settings.playerConfigs.confirm.deleteHeader'),
|
||||
acceptLabel: t('settings.playerConfigs.confirm.deleteAccept'),
|
||||
rejectLabel: t('settings.playerConfigs.confirm.deleteReject'),
|
||||
accept: async () => {
|
||||
deletingId.value = config.id;
|
||||
try {
|
||||
await rpcClient.deletePlayerConfig({ id: config.id });
|
||||
await refetch();
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: t('settings.playerConfigs.toast.deletedSummary'),
|
||||
detail: t('settings.playerConfigs.toast.deletedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (value: any) {
|
||||
console.error(value);
|
||||
showActionErrorToast(value);
|
||||
} finally {
|
||||
deletingId.value = null;
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.playerConfigs.title')"
|
||||
:description="t('settings.content.playerConfigs.subtitle')"
|
||||
bodyClass=""
|
||||
>
|
||||
<template #header-actions>
|
||||
<PlayerConfigsToolbar :can-create-config="canCreateConfig" @create="openAddDialog" />
|
||||
</template>
|
||||
|
||||
<PlayerConfigsNotices
|
||||
:is-free-plan="isFreePlan"
|
||||
:is-free-reconciliation-mode="isFreeReconciliationMode"
|
||||
/>
|
||||
|
||||
<PlayerConfigsTable
|
||||
:configs="configs"
|
||||
:is-initial-loading="isInitialLoading"
|
||||
:can-manage-existing-config="canManageExistingConfig"
|
||||
:can-delete-config="canDeleteConfig"
|
||||
:saving="saving"
|
||||
:deleting-id="deletingId"
|
||||
:toggling-id="togglingId"
|
||||
:defaulting-id="defaultingId"
|
||||
@edit="openEditDialog"
|
||||
@delete="handleDelete"
|
||||
@toggle-active="handleToggle($event.config, $event.value)"
|
||||
@set-default="handleSetDefault"
|
||||
/>
|
||||
|
||||
<PlayerConfigDialog
|
||||
:visible="showAddDialog"
|
||||
:editing-config="editingConfig"
|
||||
:form-data="formData"
|
||||
:saving="saving"
|
||||
:can-edit-dialog="canEditDialog"
|
||||
:can-submit="canSubmitDialog"
|
||||
@update:visible="showAddDialog = $event"
|
||||
@update:form-data="formData = $event"
|
||||
@save="handleSave"
|
||||
@close="closeDialog"
|
||||
/>
|
||||
</SettingsSectionCard>
|
||||
</template>
|
||||
@@ -0,0 +1,279 @@
|
||||
<script setup lang="ts">
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppDialog from '@/components/ui/AppDialog.vue';
|
||||
import AppInput from '@/components/ui/AppInput.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed } from 'vue';
|
||||
import type { PlayerConfigFormData } from '../types';
|
||||
|
||||
type FormBooleanKey =
|
||||
| 'autoplay'
|
||||
| 'loop'
|
||||
| 'muted'
|
||||
| 'showControls'
|
||||
| 'pip'
|
||||
| 'airplay'
|
||||
| 'chromecast'
|
||||
| 'encrytionM3u8'
|
||||
| 'isDefault';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
editingConfig: { isActive: boolean } | null;
|
||||
formData: PlayerConfigFormData;
|
||||
saving: boolean;
|
||||
canEditDialog: boolean;
|
||||
canSubmit: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'update:formData', value: PlayerConfigFormData): void;
|
||||
(e: 'save'): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const title = computed(() => props.editingConfig
|
||||
? t('settings.playerConfigs.dialog.editTitle')
|
||||
: t('settings.playerConfigs.dialog.createTitle'));
|
||||
|
||||
const canToggleDefault = computed(() => props.canEditDialog && (!props.editingConfig || props.editingConfig.isActive));
|
||||
|
||||
const defaultHint = computed(() => props.editingConfig && !props.editingConfig.isActive
|
||||
? t('settings.playerConfigs.dialog.defaultDisabledHint')
|
||||
: t('settings.playerConfigs.dialog.defaultHint'));
|
||||
|
||||
const updateTextField = (key: 'name' | 'description' | 'logoUrl', value: string | number | null) => {
|
||||
emit('update:formData', {
|
||||
...props.formData,
|
||||
[key]: typeof value === 'string' ? value : value == null ? '' : String(value),
|
||||
});
|
||||
};
|
||||
|
||||
const updateCheckboxField = (key: FormBooleanKey, event: Event) => {
|
||||
emit('update:formData', {
|
||||
...props.formData,
|
||||
[key]: (event.target as HTMLInputElement).checked,
|
||||
});
|
||||
};
|
||||
|
||||
const optionCardClass = (disabled: boolean) => [
|
||||
'flex items-start gap-3 rounded-md border border-border p-3 transition-colors',
|
||||
disabled ? 'cursor-not-allowed opacity-60' : 'cursor-pointer hover:border-primary/50',
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="visible"
|
||||
:title="title"
|
||||
maxWidthClass="max-w-2xl"
|
||||
@update:visible="emit('update:visible', $event)"
|
||||
@close="emit('close')"
|
||||
>
|
||||
<div class="space-y-4">
|
||||
<div class="grid gap-2">
|
||||
<label for="name" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.name') }}</label>
|
||||
<AppInput
|
||||
id="name"
|
||||
:model-value="formData.name"
|
||||
:disabled="!canEditDialog"
|
||||
:placeholder="t('settings.playerConfigs.dialog.namePlaceholder')"
|
||||
@update:model-value="updateTextField('name', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label for="description" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.description') }}</label>
|
||||
<AppInput
|
||||
id="description"
|
||||
:model-value="formData.description"
|
||||
:disabled="!canEditDialog"
|
||||
:placeholder="t('settings.playerConfigs.dialog.descriptionPlaceholder')"
|
||||
@update:model-value="updateTextField('description', $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.playbackOptions') }}</label>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.autoplay"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('autoplay', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.autoplay.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.autoplay.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.loop"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('loop', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.loop.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.loop.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.muted"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('muted', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.muted.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.muted.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.showControls"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('showControls', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.showControls.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.showControls.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.castingOptions') }}</label>
|
||||
|
||||
<div class="grid grid-cols-3 gap-3">
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.pip"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('pip', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.pip.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.pip.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.airplay"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('airplay', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.airplay.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.airplay.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.chromecast"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('chromecast', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.chromecast.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.chromecast.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-3">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.advancedOptions') }}</label>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<label :class="optionCardClass(!canEditDialog)">
|
||||
<input
|
||||
:checked="formData.encrytionM3u8"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canEditDialog"
|
||||
@change="updateCheckboxField('encrytionM3u8', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.items.encrytionM3u8.title') }}</p>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.items.encrytionM3u8.description') }}</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-2 rounded-md border border-border p-3">
|
||||
<label for="logoUrl" class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.logoUrl') }}</label>
|
||||
<AppInput
|
||||
id="logoUrl"
|
||||
:model-value="formData.logoUrl"
|
||||
:disabled="!canEditDialog"
|
||||
:placeholder="t('settings.playerConfigs.dialog.logoUrlPlaceholder')"
|
||||
@update:model-value="updateTextField('logoUrl', $event)"
|
||||
/>
|
||||
<p class="text-xs text-foreground/60">{{ t('settings.playerConfigs.dialog.logoUrlHint') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="text-sm font-medium text-foreground">{{ t('settings.playerConfigs.dialog.defaultLabel') }}</label>
|
||||
<label
|
||||
:class="[
|
||||
'flex items-start gap-3 rounded-md border border-border p-3',
|
||||
canToggleDefault && !saving ? 'cursor-pointer hover:border-primary/50' : 'opacity-60 cursor-not-allowed',
|
||||
]"
|
||||
>
|
||||
<input
|
||||
:checked="formData.isDefault"
|
||||
type="checkbox"
|
||||
class="mt-1 h-4 w-4 rounded border-border"
|
||||
:disabled="!canToggleDefault || saving"
|
||||
@change="updateCheckboxField('isDefault', $event)"
|
||||
/>
|
||||
<div>
|
||||
<p class="text-sm text-foreground">{{ t('settings.playerConfigs.dialog.defaultCheckbox') }}</p>
|
||||
<p class="mt-0.5 text-xs text-foreground/60">{{ defaultHint }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('close')">
|
||||
{{ t('common.cancel') }}
|
||||
</AppButton>
|
||||
<AppButton size="sm" :loading="saving" :disabled="!canSubmit" @click="emit('save')">
|
||||
<template #icon>
|
||||
<CheckIcon class="h-4 w-4" />
|
||||
</template>
|
||||
{{ editingConfig ? t('settings.playerConfigs.dialog.update') : t('settings.playerConfigs.dialog.create') }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import type { PlayerConfig } from '../types';
|
||||
import { computed } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
const props = defineProps<{
|
||||
config: PlayerConfig;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const badges = computed(() => {
|
||||
const values: Array<{ label: string; color: string }> = [];
|
||||
|
||||
if (props.config.autoplay) values.push({ label: t('settings.playerConfigs.badges.autoplay'), color: 'bg-blue-500/10 text-blue-500' });
|
||||
if (props.config.loop) values.push({ label: t('settings.playerConfigs.badges.loop'), color: 'bg-green-500/10 text-green-500' });
|
||||
if (props.config.muted) values.push({ label: t('settings.playerConfigs.badges.muted'), color: 'bg-yellow-500/10 text-yellow-500' });
|
||||
if (props.config.showControls) values.push({ label: t('settings.playerConfigs.badges.controls'), color: 'bg-purple-500/10 text-purple-500' });
|
||||
if (props.config.pip) values.push({ label: t('settings.playerConfigs.badges.pip'), color: 'bg-pink-500/10 text-pink-500' });
|
||||
if (props.config.airplay) values.push({ label: t('settings.playerConfigs.badges.airplay'), color: 'bg-indigo-500/10 text-indigo-500' });
|
||||
if (props.config.chromecast) values.push({ label: t('settings.playerConfigs.badges.chromecast'), color: 'bg-red-500/10 text-red-500' });
|
||||
if (props.config.encrytionM3u8) values.push({ label: t('settings.playerConfigs.badges.encrytionM3u8'), color: 'bg-amber-500/10 text-amber-500' });
|
||||
if (props.config.logoUrl) values.push({ label: t('settings.playerConfigs.badges.logo'), color: 'bg-sky-500/10 text-sky-500' });
|
||||
|
||||
return values;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex max-w-[280px] flex-wrap gap-1">
|
||||
<span
|
||||
v-for="badge in badges.slice(0, 4)"
|
||||
:key="badge.label"
|
||||
:class="['rounded px-1.5 py-0.5 text-xs font-medium', badge.color]"
|
||||
>
|
||||
{{ badge.label }}
|
||||
</span>
|
||||
<span v-if="badges.length > 4" class="text-xs text-foreground/50">+{{ badges.length - 4 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
isFreePlan: boolean;
|
||||
isFreeReconciliationMode: boolean;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
|
||||
{{ t('settings.playerConfigs.infoBanner') }}
|
||||
</SettingsNotice>
|
||||
|
||||
<SettingsNotice
|
||||
v-if="isFreePlan"
|
||||
tone="warning"
|
||||
:title="t(isFreeReconciliationMode ? 'settings.playerConfigs.reconciliationTitle' : 'settings.playerConfigs.freePlanTitle')"
|
||||
class="rounded-none border-x-0 border-t-0 p-3"
|
||||
contentClass="text-xs text-foreground/70"
|
||||
>
|
||||
{{ t(isFreeReconciliationMode ? 'settings.playerConfigs.reconciliationMessage' : 'settings.playerConfigs.freePlanMessage') }}
|
||||
</SettingsNotice>
|
||||
</template>
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import LinkIcon from '@/components/icons/LinkIcon.vue';
|
||||
import PencilIcon from '@/components/icons/PencilIcon.vue';
|
||||
import TrashIcon from '@/components/icons/TrashIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import AppSwitch from '@/components/ui/AppSwitch.vue';
|
||||
import BaseTable from '@/components/ui/BaseTable.vue';
|
||||
import SettingsTableSkeleton from '@/routes/settings/components/SettingsTableSkeleton.vue';
|
||||
import type { ColumnDef } from '@tanstack/vue-table';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, h } from 'vue';
|
||||
import PlayerConfigSettingsBadges from './PlayerConfigSettingsBadges.vue';
|
||||
import type { PlayerConfig } from '../types';
|
||||
|
||||
const props = defineProps<{
|
||||
configs: PlayerConfig[];
|
||||
isInitialLoading: boolean;
|
||||
canManageExistingConfig: boolean;
|
||||
canDeleteConfig: boolean;
|
||||
saving: boolean;
|
||||
deletingId: string | null;
|
||||
togglingId: string | null;
|
||||
defaultingId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'edit', config: PlayerConfig): void;
|
||||
(e: 'delete', config: PlayerConfig): void;
|
||||
(e: 'toggle-active', payload: { config: PlayerConfig; value: boolean }): void;
|
||||
(e: 'set-default', config: PlayerConfig): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const columns = computed<ColumnDef<PlayerConfig>[]>(() => [
|
||||
{
|
||||
id: 'config',
|
||||
header: t('settings.playerConfigs.table.name'),
|
||||
accessorFn: row => row.name,
|
||||
cell: ({ row }) => h('div', [
|
||||
h('div', { class: 'flex flex-wrap items-center gap-2' }, [
|
||||
h('span', { class: 'text-sm font-medium text-foreground cursor-pointer hover:underline', onClick: () => emit('edit', row.original) }, row.original.name),
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary',
|
||||
}, t('settings.playerConfigs.defaultBadge'))
|
||||
: null,
|
||||
]),
|
||||
row.original.description
|
||||
? h('p', { class: 'mt-0.5 text-xs text-foreground/50' }, row.original.description)
|
||||
: h('p', { class: 'mt-0.5 text-xs text-foreground/40' }, t('settings.playerConfigs.createdOn', { date: row.original.createdAt || '-' })),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
header: t('settings.playerConfigs.table.settings'),
|
||||
accessorFn: row => [
|
||||
row.autoplay ? 'autoplay' : '',
|
||||
row.loop ? 'loop' : '',
|
||||
row.muted ? 'muted' : '',
|
||||
row.showControls ? 'controls' : '',
|
||||
row.pip ? 'pip' : '',
|
||||
row.airplay ? 'airplay' : '',
|
||||
row.chromecast ? 'chromecast' : '',
|
||||
row.encrytionM3u8 ? 'encrytionM3u8' : '',
|
||||
row.logoUrl ? 'logo' : '',
|
||||
].filter(Boolean).join(', '),
|
||||
cell: ({ row }) => h(PlayerConfigSettingsBadges, { config: row.original }),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
header: t('common.status'),
|
||||
accessorFn: row => Number(row.isActive),
|
||||
cell: ({ row }) => h('div', { class: 'text-center' }, [
|
||||
h(AppSwitch, {
|
||||
modelValue: row.original.isActive,
|
||||
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.defaultingId !== null || props.togglingId === row.original.id,
|
||||
'onUpdate:modelValue': (value: boolean) => emit('toggle-active', { config: row.original, value }),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50',
|
||||
cellClass: 'px-6 py-3 text-center',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: t('common.actions'),
|
||||
enableSorting: false,
|
||||
cell: ({ row }) => h('div', { class: 'flex flex-wrap items-center justify-end gap-2' }, [
|
||||
row.original.isDefault
|
||||
? h('span', {
|
||||
class: 'inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary',
|
||||
}, t('settings.playerConfigs.actions.default'))
|
||||
: h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
loading: props.defaultingId === row.original.id,
|
||||
disabled: !props.canManageExistingConfig || props.saving || props.deletingId !== null || props.togglingId !== null || props.defaultingId !== null || !row.original.isActive,
|
||||
onClick: () => emit('set-default', row.original),
|
||||
}, () => t('settings.playerConfigs.actions.setDefault')),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: !props.canManageExistingConfig,
|
||||
onClick: () => emit('edit', row.original),
|
||||
}, {
|
||||
icon: () => h(PencilIcon, { class: 'h-4 w-4' }),
|
||||
}),
|
||||
h(AppButton, {
|
||||
variant: 'ghost',
|
||||
size: 'sm',
|
||||
disabled: !props.canDeleteConfig,
|
||||
onClick: () => emit('delete', row.original),
|
||||
}, {
|
||||
icon: () => h(TrashIcon, { class: 'h-4 w-4 text-danger' }),
|
||||
}),
|
||||
]),
|
||||
meta: {
|
||||
headerClass: 'px-6 py-3 text-center text-xs font-medium uppercase tracking-wider text-foreground/50 [&>div]:justify-center',
|
||||
cellClass: 'px-6 py-3 text-right',
|
||||
},
|
||||
},
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsTableSkeleton v-if="isInitialLoading" :columns="5" :rows="4" />
|
||||
|
||||
<BaseTable
|
||||
v-else
|
||||
:data="configs"
|
||||
:columns="columns"
|
||||
:get-row-id="(row) => row.id"
|
||||
wrapperClass="mt-4 border-b border-border rounded-none border-x-0 border-t-0 bg-transparent"
|
||||
tableClass="w-full"
|
||||
headerRowClass="bg-muted/30"
|
||||
bodyRowClass="border-b border-border hover:bg-muted/30"
|
||||
>
|
||||
<template #empty>
|
||||
<div class="px-6 py-12 text-center">
|
||||
<LinkIcon class="mx-auto mb-3 block h-10 w-10 text-foreground/30" />
|
||||
<p class="mb-1 text-sm text-foreground/60">{{ t('settings.playerConfigs.emptyTitle') }}</p>
|
||||
<p class="text-xs text-foreground/40">{{ t('settings.playerConfigs.emptySubtitle') }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</BaseTable>
|
||||
</template>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import PlusIcon from '@/components/icons/PlusIcon.vue';
|
||||
import AppButton from '@/components/ui/AppButton.vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
|
||||
defineProps<{
|
||||
canCreateConfig: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'create'): void;
|
||||
}>();
|
||||
|
||||
const { t } = useTranslation();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppButton size="sm" :disabled="!canCreateConfig" @click="emit('create')">
|
||||
<template #icon>
|
||||
<PlusIcon class="h-4 w-4" />
|
||||
</template>
|
||||
{{ t('settings.playerConfigs.createConfig') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
50
src/routes/settings/PlayerConfigs/types.ts
Normal file
50
src/routes/settings/PlayerConfigs/types.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
export interface PlayerConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
showControls: boolean;
|
||||
pip: boolean;
|
||||
airplay: boolean;
|
||||
chromecast: boolean;
|
||||
encrytionM3u8: boolean;
|
||||
logoUrl?: string;
|
||||
isActive: boolean;
|
||||
isDefault: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type PlayerConfigApiItem = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
description?: string | null;
|
||||
autoplay?: boolean;
|
||||
loop?: boolean;
|
||||
muted?: boolean;
|
||||
showControls?: boolean | null;
|
||||
pip?: boolean | null;
|
||||
airplay?: boolean | null;
|
||||
chromecast?: boolean | null;
|
||||
encrytionM3u8?: boolean | null;
|
||||
logoUrl?: string | null;
|
||||
isActive?: boolean | null;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
export interface PlayerConfigFormData {
|
||||
name: string;
|
||||
description: string;
|
||||
autoplay: boolean;
|
||||
loop: boolean;
|
||||
muted: boolean;
|
||||
showControls: boolean;
|
||||
pip: boolean;
|
||||
airplay: boolean;
|
||||
chromecast: boolean;
|
||||
encrytionM3u8: boolean;
|
||||
logoUrl: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
@@ -1,167 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { client } from '@/api/client';
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppSwitch from '@/components/app/AppSwitch.vue';
|
||||
import CheckIcon from '@/components/icons/CheckIcon.vue';
|
||||
import {
|
||||
createPlayerSettingsDraft,
|
||||
toPlayerPreferencesPayload,
|
||||
useSettingsPreferencesQuery,
|
||||
} from '@/composables/useSettingsPreferencesQuery';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
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 { computed, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const toast = useAppToast();
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
|
||||
|
||||
const playerSettings = ref(createPlayerSettingsDraft());
|
||||
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,
|
||||
});
|
||||
onMounted(() => {
|
||||
router.replace({ name: 'settings-player-configs' });
|
||||
});
|
||||
|
||||
const handleSave = async () => {
|
||||
if (saving.value || !preferencesSnapshot.value) return;
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await client.settings.preferencesUpdate(
|
||||
toPlayerPreferencesPayload(playerSettings.value),
|
||||
{ baseUrl: '/r' },
|
||||
);
|
||||
await refetch();
|
||||
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: t('settings.playerSettings.toast.savedSummary'),
|
||||
detail: t('settings.playerSettings.toast.savedDetail'),
|
||||
life: 3000,
|
||||
});
|
||||
} catch (e: any) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: t('settings.playerSettings.toast.failedSummary'),
|
||||
detail: e.message || t('settings.playerSettings.toast.failedDetail'),
|
||||
life: 5000,
|
||||
});
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsSectionCard
|
||||
:title="t('settings.content.player.title')"
|
||||
:description="t('settings.content.player.subtitle')"
|
||||
>
|
||||
<template #header-actions>
|
||||
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
|
||||
<template #icon>
|
||||
<CheckIcon class="w-4 h-4" />
|
||||
</template>
|
||||
{{ t('common.save') }}
|
||||
</AppButton>
|
||||
</template>
|
||||
|
||||
<template v-if="isInitialLoading">
|
||||
<SettingsRowSkeleton
|
||||
v-for="item in settingsItems"
|
||||
:key="item.key"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<SettingsRow
|
||||
v-for="item in settingsItems"
|
||||
: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>
|
||||
<div class="p-4 text-sm text-foreground/60">Redirecting...</div>
|
||||
</template>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user