16 Commits

Author SHA1 Message Date
b60f65e4d1 feat: add admin components for input, metrics, tables, and user forms
- Introduced AdminInput component for standardized input fields.
- Created AdminMetricCard for displaying metrics with customizable tones.
- Added AdminPlaceholderTable for loading states in tables.
- Developed AdminSectionCard for consistent section layouts.
- Implemented AdminSectionShell for organizing admin sections.
- Added AdminSelect for dropdown selections with v-model support.
- Created AdminTable for displaying tabular data with loading and empty states.
- Introduced AdminTextarea for multi-line text input.
- Developed AdminUserFormFields for user creation and editing forms.
- Added useAdminPageHeader composable for managing admin page header state.
2026-03-24 07:08:44 +00:00
e854c68ad0 fix: clean up imports and remove shadow effect from loading bar 2026-03-19 09:53:06 +07:00
b787cd161a Refactor admin routes and implement S3 manifest handling
- Updated video detail modal to use new ad template property naming convention.
- Refactored RPC routes to include admin methods for user, video, payment, plan, and ad template management.
- Introduced S3 helper functions for manifest creation, saving, fetching, and validation of chunk URLs.
- Added new admin methods for managing jobs and agents.
- Created a new UserIcon component for better icon management.
- Enhanced validation functions to support multiple schemas.
2026-03-19 01:43:49 +07:00
bd8b21955e feat: add BaseTable component for improved table rendering
- Introduced a new BaseTable component to enhance table functionality with sorting and loading states.
- Updated upload queue logic to support chunk uploads and improved error handling.
- Refactored various admin routes to utilize the new BaseTable component.
- Adjusted import paths for UI components to maintain consistency.
- Enhanced upload handling with better progress tracking and cancellation support.
- Updated theme colors in uno.config.ts for a more cohesive design.
2026-03-18 22:23:11 +07:00
87c99e64cd feat: add AsyncSelect component and update related types and headers handling 2026-03-17 22:49:58 +07:00
baa8811e9e add jobid to video 2026-03-17 13:15:18 +00:00
fa88fe26b3 feat: refactor billing plans section and remove unused components
- Updated BillingPlansSection.vue to clean up unused code and improve readability.
- Removed CardPopover.vue and VideoGrid.vue components as they were no longer needed.
- Enhanced VideoTable.vue by integrating BaseTable for better table management and added loading states.
- Introduced secure JSON transformer for enhanced data security in RPC routes.
- Added key resolver for managing server key pairs.
- Created a script to generate NaCl keys for secure communications.
- Implemented admin page header management for better UI consistency.
2026-03-17 18:54:14 +07:00
90d8409aa9 refactor: update UI styles to use new header background color
- Changed background color for various select elements and containers in Users.vue and Videos.vue to use 'bg-header'.
- Updated background color for status and role filters in the admin section.
- Adjusted background colors in Home.vue, QuickActions.vue, and other components to enhance UI consistency.
- Refactored Billing.vue and DomainsDns.vue to align with new design standards.
- Modified settings components to utilize new header color for better visual hierarchy.
- Improved accessibility and visual feedback in the SettingsRow and SettingsSectionCard components.
- Updated authentication middleware to include timestamp cookie for session management.
- Enhanced gRPC client to build internal metadata for service calls.
2026-03-16 17:09:31 +07:00
b4bbacd9f1 add getuserbyid method 2026-03-16 08:31:02 +00:00
8b85736903 fix: use Bun RedisClient type in server setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:50:37 +00:00
3beabcfe7f update migrate 2026-03-12 15:17:31 +00:00
35117b7be9 feat: update settings and package configurations to include estree-walker and enhance script permissions 2026-03-12 21:56:56 +07:00
e3587eff71 feat: enhance gRPC authentication flow and improve token management 2026-03-12 17:56:55 +07:00
57903b80b6 update grpc 2026-03-12 09:33:28 +00:00
5c0ca0e139 feat: refactor authentication and user management routes
- Removed the API proxy middleware and integrated RPC routes for user authentication.
- Implemented JWT token generation and validation in the authentication middleware.
- Enhanced user registration and login processes with password hashing and token management.
- Added new routes for user password reset and Google OAuth login.
- Introduced health check endpoints for service monitoring.
- Updated gRPC client methods for user management, including password updates.
- Refactored utility functions for token handling and Redis interactions.
- Improved type definitions for better TypeScript support.
2026-03-11 23:57:14 +07:00
9276603a70 feat: implement JWT token provider with access and refresh token generation 2026-03-11 19:01:23 +07:00
181 changed files with 40380 additions and 5099 deletions

View File

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

@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE

303
bun.lock
View File

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

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

Binary file not shown.

View File

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

View File

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

View File

@@ -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 vinh 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ảnnh 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
View 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"));

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,13 @@
<script lang="ts" setup>
import Bell from "@/components/icons/Bell.vue";
import Home from "@/components/icons/Home.vue";
import Video from "@/components/icons/Video.vue";
import LayoutDashboard from "@/components/icons/LayoutDashboard.vue";
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
// import Upload from "@/components/icons/Upload.vue";
import Video from "@/components/icons/Video.vue";
import { cn } from "@/lib/utils";
import { 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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

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

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

View File

@@ -0,0 +1,58 @@
import { TinyMqttClient } from "@/lib/liteMqtt";
import { useAuthStore } from "@/stores/auth";
import { computed, onBeforeUnmount, watch } from "vue";
type RuntimeMessage = {
topic: string;
payload: any;
};
const mqttBrokerUrl = "wss://mqtt-dashboard.com:8884/mqtt";
export function useAdminRuntimeMqtt(onMessage: (message: RuntimeMessage) => void) {
const auth = useAuthStore();
let client: TinyMqttClient | undefined;
const isAdmin = computed(() => auth.user?.role?.toUpperCase?.() === "ADMIN");
const connect = () => {
if (import.meta.env.SSR || !isAdmin.value) return;
disconnect();
client = new TinyMqttClient(
mqttBrokerUrl,
["picpic/events", "picpic/logs/#", "picpic/job/+"],
(topic, raw) => {
try {
onMessage({ topic, payload: JSON.parse(raw) });
} catch {
onMessage({ topic, payload: raw });
}
},
);
client.connect();
};
const disconnect = () => {
client?.disconnect();
client = undefined;
};
const stopWatch = watch(
() => [auth.user?.id, auth.user?.role],
() => {
if (isAdmin.value) {
connect();
} else {
disconnect();
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopWatch();
disconnect();
});
return { disconnect };
}

View File

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

View File

@@ -1,4 +1,4 @@
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
@@ -24,9 +24,7 @@ type NotificationApiItem = {
read?: boolean;
actionUrl?: string;
actionLabel?: string;
action_url?: string;
action_label?: string;
created_at?: string;
createdAt?: string;
};
const notifications = ref<AppNotification[]>([]);
@@ -69,18 +67,18 @@ export function useNotifications() {
type: normalizeType(item.type),
title: item.title || '',
message: item.message || '',
time: formatRelativeTime(item.created_at),
time: formatRelativeTime(item.createdAt),
read: Boolean(item.read),
actionUrl: item.actionUrl || item.action_url || undefined,
actionLabel: item.actionLabel || item.action_label || undefined,
createdAt: item.created_at,
actionUrl: item.actionUrl || undefined,
actionLabel: item.actionLabel || undefined,
createdAt: item.createdAt,
});
const fetchNotifications = async () => {
loading.value = true;
try {
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
const response = await rpcClient.listNotifications();
notifications.value = (response.notifications || []).map(mapNotification);
loaded.value = true;
return notifications.value;
} finally {
@@ -90,24 +88,24 @@ export function useNotifications() {
const markRead = async (id: string) => {
if (!id) return;
await client.notifications.readCreate(id, { baseUrl: '/r' });
await rpcClient.markNotificationRead({ id });
const item = notifications.value.find(notification => notification.id === id);
if (item) item.read = true;
};
const deleteNotification = async (id: string) => {
if (!id) return;
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
await rpcClient.deleteNotification({ id });
notifications.value = notifications.value.filter(notification => notification.id !== id);
};
const markAllRead = async () => {
await client.notifications.readAllCreate({ baseUrl: '/r' });
await rpcClient.markAllNotificationsRead();
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
};
const clearAll = async () => {
await client.notifications.notificationsDelete({ baseUrl: '/r' });
await rpcClient.clearNotifications();
notifications.value = [];
};

View File

@@ -1,4 +1,6 @@
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { Preferences } from '@/server/gen/proto/app/v1/common';
import type { UpdatePreferencesRequest } from '@/server/gen/proto/app/v1/account';
import { useQuery } from '@pinia/colada';
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
@@ -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);
},
});
}

View File

@@ -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(() => {
@@ -395,4 +368,4 @@ export function useUploadQueue() {
remainingSlots,
maxItems: MAX_ITEMS,
};
}
}

View File

@@ -1,4 +1,4 @@
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import { useQuery } from '@pinia/colada';
export const USAGE_QUERY_KEY = ['usage'] as const;
@@ -9,10 +9,8 @@ export type UsageSnapshot = {
};
type UsageResponse = {
data?: {
total_videos?: number;
total_storage?: number;
};
totalVideos?: number;
totalStorage?: number;
};
const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
@@ -21,11 +19,11 @@ const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
};
const normalizeUsageSnapshot = (responseData: unknown): UsageSnapshot => {
const usage = (responseData as UsageResponse | undefined)?.data;
const usage = responseData as UsageResponse | undefined;
return {
totalVideos: usage?.total_videos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
totalStorage: usage?.total_storage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
totalVideos: usage?.totalVideos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
totalStorage: usage?.totalStorage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
};
};
@@ -33,8 +31,8 @@ export function useUsageQuery() {
return useQuery({
key: () => USAGE_QUERY_KEY,
query: async () => {
const response = await client.usage.usageList({ baseUrl: '/r' });
return normalizeUsageSnapshot(response.data);
const response = await rpcClient.getUsage();
return normalizeUsageSnapshot(response);
},
});
}

View File

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

View File

@@ -96,4 +96,8 @@ export const getStatusSeverity = (status: string = "") => {
default:
return 'info';
}
};
export const isAdmin = (role: string = "") => {
const r = String(role).toLowerCase();
return r === "admin" || r === "superadmin";
};

View File

@@ -1,4 +1,4 @@
import type { ModelVideo } from "@/api/client";
import type { Video as ModelVideo } from "@/server/gen/proto/app/v1/common";
export const mockVideos: ModelVideo[] = [
{
@@ -9,7 +9,7 @@ export const mockVideos: ModelVideo[] = [
duration: 345, // 5m 45s
status: 'ready',
size: 1024 * 1024 * 45, // 45MB
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2).toISOString(), // 2 days ago
views: 12500,
url: '#'
},
@@ -20,9 +20,8 @@ export const mockVideos: ModelVideo[] = [
thumbnail: 'https://picsum.photos/seed/video2/640/360',
duration: 890, // 14m 50s
status: 'processing',
processing_status: '75%',
size: 1024 * 1024 * 128, // 128MB
created_at: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 5).toISOString(), // 5 hours ago
views: 0,
url: '#'
},
@@ -34,7 +33,7 @@ export const mockVideos: ModelVideo[] = [
duration: 120, // 2m 00s
status: 'ready',
size: 1024 * 1024 * 25, // 25MB
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 7).toISOString(), // 1 week ago
views: 340,
url: '#'
},
@@ -46,7 +45,7 @@ export const mockVideos: ModelVideo[] = [
duration: 1800, // 30m 00s
status: 'ready',
size: 1024 * 1024 * 350, // 350MB
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 14).toISOString(), // 2 weeks ago
views: 12,
url: '#'
},
@@ -58,7 +57,7 @@ export const mockVideos: ModelVideo[] = [
duration: 600, // 10m 00s
status: 'failed',
size: 1024 * 1024 * 80, // 80MB
created_at: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
createdAt: new Date(Date.now() - 1000 * 60 * 30).toISOString(), // 30 mins ago
views: 0,
url: '#'
},
@@ -70,7 +69,7 @@ export const mockVideos: ModelVideo[] = [
duration: 5400, // 1h 30m
status: 'ready',
size: 1024 * 1024 * 1024 * 2.5, // 2.5GB
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 30).toISOString(), // 1 month ago
views: 45000,
url: '#'
},
@@ -82,7 +81,7 @@ export const mockVideos: ModelVideo[] = [
duration: 1540,
status: 'ready',
size: 1024 * 1024 * 200,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 3).toISOString(),
views: 8900,
url: '#'
},
@@ -94,7 +93,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -106,7 +105,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -118,7 +117,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -130,7 +129,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -142,7 +141,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -154,7 +153,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -166,7 +165,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -178,7 +177,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -190,7 +189,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -202,7 +201,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -214,7 +213,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -226,7 +225,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -238,7 +237,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -250,7 +249,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -262,7 +261,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -274,7 +273,7 @@ export const mockVideos: ModelVideo[] = [
duration: 3200,
status: 'ready',
size: 1024 * 1024 * 800,
created_at: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 48).toISOString(),
views: 1500,
url: '#'
},
@@ -340,7 +339,7 @@ export const updateMockVideo = async (id: string, updates: { title: string; desc
...mockVideos[videoIndex],
title: updates.title,
description: updates.description,
updated_at: new Date().toISOString()
updatedAt: new Date().toISOString()
};
return mockVideos[videoIndex];
};

View File

@@ -28,7 +28,7 @@
</template>
<script setup lang="ts">
import { client } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import { useAppToast } from '@/composables/useAppToast';
import { reactive } from 'vue';
import { useTranslation } from 'i18next-vue';
@@ -59,7 +59,7 @@ const onFormSubmit = () => {
return;
}
client.auth.forgotPasswordCreate({ email: form.email })
rpcClient.forgotPassword({ email: form.email })
.then(() => {
toast.add({
severity: 'success',

View File

@@ -20,11 +20,9 @@ const status = computed(() => String(route.query.status ?? 'error'));
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
const reasonMessages: Record<string, string> = {
missing_state: 'Google login session is invalid. Please try again.',
invalid_state: 'Google login session has expired. Please try again.',
missing_code: 'Google did not return an authorization code.',
access_denied: 'Google login was cancelled.',
exchange_failed: 'Failed to sign in with Google.',
exchange_failed: 'Failed to verify your Google sign-in. Please try again.',
userinfo_failed: 'Failed to load your Google account information.',
userinfo_parse_failed: 'Failed to read your Google account information.',
missing_email: 'Your Google account did not provide an email address.',

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ import {
} from "vue-router";
type RouteData = RouteRecordRaw & {
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean; requiresAdmin?: boolean };
children?: RouteData[];
};
const routes: RouteData[] = [
@@ -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()

View File

@@ -1,5 +1,6 @@
<script setup lang="tsx">
import { client, type ModelVideo } from '@/api/client';
import { client as rpcClient } from '@/api/rpcclient';
import type { Video as ModelVideo } from '@/server/gen/proto/app/v1/common';
import { useUsageQuery } from '@/composables/useUsageQuery';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { computed, onMounted, ref } from 'vue';
@@ -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>

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -0,0 +1,11 @@
export type DomainApiItem = {
id?: string;
name?: string;
created_at?: string;
};
export type DomainItem = {
id: string;
name: string;
addedAt: string;
};

View File

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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