7 Commits

Author SHA1 Message Date
dc06412f79 done ui 2026-03-11 02:43:33 +00:00
edc1a33547 done i18n 2026-03-06 18:46:21 +00:00
3c24da4af8 refactor: remove i18n dependency and related code
- Removed the i18n module and its related functions from the project.
- Eliminated the usage of getActiveI18n and related locale handling in various components and stores.
- Updated translation handling to use a new instance creation method.
- Cleaned up unused imports and code related to language detection and cookie management.
- Adjusted components to directly utilize the new translation setup.
2026-03-06 12:45:29 +07:00
3491a0a08e fix i18n runtime scoping for SSR requests
Create a dedicated i18next instance per SSR request and remove server context-storage coupling from translation runtime, while keeping a separate client singleton path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:02:06 +00:00
6d04f1cbdc replace vue-i18n with i18next-vue
Complete the i18n migration by switching runtime setup and remaining components to i18next-vue, and add shared locale constants/helpers for SSR and client language handling.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 02:11:46 +00:00
bbe15d5f3e remove vue-i18n 2026-03-06 00:08:51 +07:00
dba9713d96 add change language 2026-03-05 09:21:06 +00:00
124 changed files with 12987 additions and 4604 deletions

View File

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

View File

@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
This file provides guidance for AI coding agents working with the Holistream codebase. This file provides guidance for AI coding agents working with the Holistream codebase.
hallo
## Project Overview ## Project Overview
**Holistream** is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities for content creators. **Holistream** is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities for content creators.

219
CLAUDE.md
View File

@@ -2,197 +2,82 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview ## Project overview
Holistream is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities. `stream-ui` is a Vue 3 SSR frontend deployed on Cloudflare Workers. It uses Hono as the Worker server layer and a custom Vite SSR setup rather than Nuxt.
## Technology Stack ## Common commands
- **Framework**: Vue 3 with JSX/TSX support Run all commands from `stream-ui/`.
- **Router**: Vue Router 5 with SSR-aware history
- **Server**: Hono framework on Cloudflare Workers
- **Build Tool**: Vite 7 with custom SSR plugin
- **Styling**: UnoCSS (Tailwind-like utility-first CSS)
- **UI Components**: PrimeVue 4 with Aura theme
- **State Management**: Pinia + Pinia Colada for server state
- **HTTP Client**: Auto-generated from OpenAPI spec via swagger-typescript-api
- **Package Manager**: Bun
## Common Commands
```bash ```bash
# Development server with hot reload # Install dependencies
bun dev bun install
# Production build (client + worker) # Start local dev server
bun run dev
# Build client + worker bundles
bun run build bun run build
# Preview production build locally # Preview production build locally
bun preview bun run preview
# Deploy to Cloudflare Workers # Deploy to Cloudflare Workers
bun run deploy bun run deploy
# Generate TypeScript types from Wrangler config # Regenerate Cloudflare binding types from Wrangler config
bun run cf-typegen bun run cf-typegen
# View Cloudflare Worker logs # Tail Cloudflare Worker logs
bun run tail bun run tail
``` ```
**Note**: The project uses Bun as the package manager. If using npm/yarn, replace `bun` with `npm run` or `yarn`. Notes:
- This project uses Bun (`bun.lock` is present).
- There is currently no configured `test` script.
- There is currently no configured `lint` script.
## Architecture ## Architecture
### SSR Architecture ### SSR entrypoints
- `src/index.tsx`: Hono Worker entry; registers middleware, proxy routes, merge/display/manifest routes, then SSR routes
- `src/main.ts`: shared app factory for SSR and client hydration
- `src/client.ts`: client-side hydration entry
- `ssrPlugin.ts`: custom Vite SSR plugin that builds the client first, injects the Vite manifest, and swaps environment-specific modules
The app uses a custom SSR setup (`ssrPlugin.ts`) that: ### Routing and app structure
- Builds the client bundle FIRST, then the Worker bundle - Routes live in `src/routes/index.ts`.
- Injects the Vite manifest into the server build for asset rendering - Routing is SSR-aware: `createMemoryHistory()` on the server and `createWebHistory()` in the browser.
- Uses environment-based module resolution for `httpClientAdapter` and `liteMqtt` - The app is split into:
- public pages
- auth pages
- protected dashboard/settings pages
- Current protected areas include `videos`, `notification`, and `settings/*` routes.
Entry points: ### State and hydration
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream - Pinia is used for app state.
- **Client**: `src/client.ts` - Hydrates the SSR-rendered app - `@pinia/colada` is used for server-state/query hydration.
- SSR serializes Pinia state into `$p` and query cache into `$colada`; `src/client.ts` restores both during hydration.
- `src/stores/auth.ts` owns session state and route guards depend on `auth.user`.
### Module Aliases ### API integration
- `src/api/client.ts` is generated by `swagger-typescript-api`; do not hand-edit generated sections.
- API access should go through the generated client and `@httpClientAdapter`, not raw `fetch`.
- `src/api/httpClientAdapter.server.ts` handles SSR-side API calls by forwarding request headers/cookies and proxying frontend `/r/*` requests to `https://api.pipic.fun`.
- `src/api/httpClientAdapter.client.ts` is the browser-side adapter.
- `@/``src/` ### Notable flows
- `@httpClientAdapter``src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) - `src/stores/auth.ts` initializes the logged-in user from `/me` and opens an MQTT connection after login.
- `@liteMqtt``src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) - `src/composables/useUploadQueue.ts` implements the custom upload queue:
- 90MB chunks
- max 3 parallel uploads
- max 3 retries
- max 5 queued items
- Styling uses UnoCSS (`uno.config.ts`).
### State Management Pattern ## Important notes
Uses **Pinia Colada** for server state with SSR hydration: - Prefer the actual current code over older documentation when they conflict.
- Queries are fetched server-side and serialized to `window.__APP_DATA__` - The previous version of this file contained stale route and dependency details; verify against `src/routes/index.ts` and `package.json` before assuming old pages or libraries still exist.
- Client hydrates the query cache on startup via `hydrateQueryCache()` - Any frontend change that affects API contracts should be checked against the backend repo (`../stream.api`) as well.
- Pinia state is similarly serialized and restored via `PiniaSharedState` plugin
### API Client Architecture
The API client (`src/api/client.ts`) is auto-generated from OpenAPI spec:
- Uses `customFetch` adapter that differs between client/server
- Server adapter (`httpClientAdapter.server.ts`): Forwards cookies via `hono/context-storage`, merges headers, calls `https://api.pipic.fun`
- Client adapter (`httpClientAdapter.client.ts`): Standard fetch with `credentials: "include"`
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun` via `apiProxyMiddleware`
- Base API URL constant: `baseAPIURL = "https://api.pipic.fun"`
### Routing Structure
Routes are defined in `src/routes/index.ts` with three main layouts:
1. **Public** (`/`): Landing page, terms, privacy
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in)
3. **Dashboard**: Protected routes requiring auth
- `/overview` - Main dashboard
- `/upload` - Video upload
- `/video` - Video list
- `/video/:id` - Video detail/edit
- `/payments-and-plans` - Billing
- `/notification`, `/profile` - User settings
Route meta supports `@unhead/vue` for SEO: `meta: { head: { title, meta: [...] } }`
### Styling System (UnoCSS)
Configuration in `uno.config.ts`:
- **Presets**: Wind4 (Tailwind), Typography, Attributify, Bootstrap buttons
- **Custom colors**: `primary` (#14a74b), `accent`, `secondary` (#fd7906), `success`, `info`, `warning`, `danger`
- **Shortcuts**: `press-animated` for button press effects
- **Transformers**: `transformerCompileClass` (prefix: `_`), `transformerVariantGroup`
Use `cn()` from `src/lib/utils.ts` for conditional class merging (clsx + tailwind-merge).
### Component Auto-Import
Components in `src/components/` are auto-imported via `unplugin-vue-components`:
- PrimeVue components resolved via `PrimeVueResolver`
- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import`
### Auth Flow
- `useAuthStore` manages auth state with cookie-based sessions
- `init()` called on every request to fetch current user via `/me` endpoint
- `beforeEach` router guard redirects unauthenticated users from protected routes
- MQTT client connects on user login for real-time notifications
### File Upload Architecture
Upload queue (`src/composables/useUploadQueue.ts`):
- Supports both local files and remote URLs
- Presigned POST URLs fetched from API
- Parallel chunk upload for large files
- Progress tracking with speed calculation
- **Chunk configuration**: 90MB chunks, max 3 parallel uploads, max 3 retries
- **Upload limits**: Max 5 items in queue
- Uses `tmpfiles.org` API for chunk uploads, `/merge` endpoint for finalizing
- Cancel support via XHR abort tracking
### Type Safety
- TypeScript strict mode enabled
- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`)
- API types auto-generated from backend OpenAPI spec
### Environment Variables
Cloudflare Worker bindings (configured in `wrangler.jsonc`):
- No explicit secrets in code - use Wrangler secrets management
- `compatibility_date`: "2025-08-03"
- `compatibility_flags`: ["nodejs_compat"]
## Important File Locations
| Purpose | Path |
|---------|------|
| Server entry | `src/index.tsx` |
| Client entry | `src/client.ts` |
| App factory | `src/main.ts` |
| Router config | `src/routes/index.ts` |
| API client | `src/api/client.ts` |
| Auth store | `src/stores/auth.ts` |
| SSR plugin | `ssrPlugin.ts` |
| UnoCSS config | `uno.config.ts` |
| Wrangler config | `wrangler.jsonc` |
| Vite config | `vite.config.ts` |
## Server Structure
Middleware and routes are organized in `src/server/`:
**Middlewares** (`src/server/middlewares/`):
- `setup.ts` - Global middleware: `contextStorage`, CORS, mobile detection via `is-mobile`
- `apiProxy.ts` - Proxies `/r/*` requests to external API
**Routes** (`src/server/routes/`):
- `ssr.ts` - Handles SSR rendering and state serialization
- `display.ts`, `merge.ts`, `manifest.ts`, `wellKnown.ts` - API endpoints
## Development Notes
- Always use `customFetch` from `@httpClientAdapter` for API calls, never raw fetch
- The `honoContext` is provided to Vue app for accessing request context in components
- MQTT client in `src/lib/liteMqtt.ts` (using `TinyMqttClient`) handles real-time notifications
- Icons are custom Vue components in `src/components/icons/`
- Upload indicator is a global component showing queue status
- Root component uses error boundary wrapper: `withErrorBoundary(RouterView)` in `src/main.ts`
- **Testing & Linting**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
## Code Organization
### Component Structure
- Keep view components small and focused - extract logical sections into child components
- Page views should compose child components, not contain all logic inline
- Example: `src/routes/settings/Settings.vue` uses child components in `src/routes/settings/components/`
- Components that exceed ~200 lines should be considered for refactoring
- Use `components/` subfolder pattern for page-specific components: `src/routes/{feature}/components/`
### Icons
- **Use custom SVG icon components** from `src/components/icons/` for UI icons (e.g., `Home`, `Video`, `Bell`, `SettingsIcon`)
- Custom icons are Vue components with `filled` prop for active/filled state
- PrimeIcons (`pi pi-*` class) should **only** be used for:
- Button icons in PrimeVue components (e.g., `icon="pi pi-check"`)
- Dialog/action icons where no custom SVG exists
- **Do NOT use** `<i class="pi pi-*">` for navigation icons, action buttons, or UI elements that have custom SVG equivalents
- When adding new icons, create SVG components in `src/components/icons/` following the existing pattern (support `filled` prop)

327
bun.lock
View File

@@ -5,30 +5,35 @@
"": { "": {
"name": "holistream", "name": "holistream",
"dependencies": { "dependencies": {
"@pinia/colada": "^0.21.2", "@hattip/adapter-node": "^0.0.49",
"@unhead/vue": "^2.1.2", "@hono/node-server": "^1.19.11",
"@vueuse/core": "^14.2.0", "@pinia/colada": "^0.21.7",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"hono": "^4.11.7", "hono": "^4.12.5",
"i18next": "^25.8.14",
"i18next-http-backend": "^3.0.2",
"i18next-vue": "^5.4.0",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"vue": "^3.5.27", "vue": "^3.5.29",
"vue-router": "^5.0.2", "vue-router": "^5.0.3",
"zod": "^4.3.6", "zod": "^4.3.6",
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.23.0", "@cloudflare/vite-plugin": "^1.26.0",
"@types/node": "^25.2.0", "@types/node": "^25.3.3",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4", "@vitejs/plugin-vue-jsx": "^5.1.4",
"unocss": "^66.6.0", "unocss": "^66.6.5",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0", "unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1", "vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2", "vite-ssr-components": "^0.5.2",
"wrangler": "^4.62.0", "wrangler": "^4.70.0",
}, },
}, },
}, },
@@ -81,6 +86,8 @@
"@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="], "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.28.6", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.6", "@babel/helper-plugin-utils": "^7.28.6", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw=="],
"@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="],
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
@@ -91,22 +98,26 @@
"@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/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.25.5", "", { "dependencies": { "@cloudflare/unenv-preset": "2.14.0", "miniflare": "4.20260302.0", "unenv": "2.0.0-rc.24", "wrangler": "4.68.1", "ws": "8.18.0" }, "peerDependencies": { "vite": "^6.1.0 || ^7.0.0" } }, "sha512-dWnJtp/4/m2XQ5Ssnxrh6rb+Jvlkd9pTZhX8MS5sNhdzoULB6vzPkdKaKnaLnYC97iL3j1I2m0gIr15QznKRjA=="], "@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.20260302.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-cGtxPByeVrgoqxbmd8qs631wuGwf8yTm/FY44dEW4HdoXrb5jhlE4oWYHFafedkQCvGjY1Vbs3puAiKnuMxTXQ=="], "@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.20260302.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WRGqV6RNXM3xoQblJJw1EHKwx9exyhB18cdnToSCUFPObFhk3fzMLoQh7S+nUHUpto6aUrXPVj6R/4G3UPjCxw=="], "@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.20260302.0", "", { "os": "linux", "cpu": "x64" }, "sha512-gG423mtUjrmlQT+W2+KisLc6qcGcBLR+QcK5x1gje3bu/dF3oNiYuqY7o58A+sQk6IB849UC4UyNclo1RhP2xw=="], "@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.20260302.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-7M25noGI4WlSBOhrIaY8xZrnn87OQKtJg9YWAO2EFqGjF1Su5QXGaLlQVF4fAKbqTywbHnI8BAuIsIlUSNkhCg=="], "@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.20260302.0", "", { "os": "win32", "cpu": "x64" }, "sha512-jK1L3ADkiWxFzlqZTq2iHW1Bd2Nzu1fmMWCGZw4sMZ2W1B2WCm2wHwO2SX/py4BgylyEN3wuF+5zagbkNKht9A=="], "@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=="], "@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
"@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="],
@@ -159,6 +170,18 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@hattip/adapter-node": ["@hattip/adapter-node@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49", "@hattip/polyfills": "0.0.49", "@hattip/walk": "0.0.49" } }, "sha512-BE+Y8Q4U0YcH34FZUYU4DssGKOaZLbNL0zK57Z41UZp0m9kS79ZIolBmjjpPhTVpIlRY3Rs+uhXbVXKk7mUcJA=="],
"@hattip/core": ["@hattip/core@0.0.49", "", {}, "sha512-3/ZJtC17cv8m6Sph8+nw4exUp9yhEf2Shi7HK6AHSUSBtaaQXZ9rJBVxTfZj3PGNOR/P49UBXOym/52WYKFTJQ=="],
"@hattip/headers": ["@hattip/headers@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49" } }, "sha512-rrB2lEhTf0+MNVt5WdW184Ky706F1Ze9Aazn/R8c+/FMUYF9yjem2CgXp49csPt3dALsecrnAUOHFiV0LrrHXA=="],
"@hattip/polyfills": ["@hattip/polyfills@0.0.49", "", { "dependencies": { "@hattip/core": "0.0.49", "@whatwg-node/fetch": "^0.9.22", "node-fetch-native": "^1.6.4" } }, "sha512-5g7W5s6Gq+HDxwULGFQ861yAnEx3yd9V8GDwS96HBZ1nM1u93vN+KTuwXvNsV7Z3FJmCrD/pgU8WakvchclYuA=="],
"@hattip/walk": ["@hattip/walk@0.0.49", "", { "dependencies": { "@hattip/headers": "0.0.49", "cac": "^6.7.14", "mime-types": "^2.1.35" }, "bin": { "hattip-walk": "cli.js" } }, "sha512-AgJgKLooZyQnzMfoFg5Mo/aHM+HGBC9ExpXIjNqGimYTRgNbL/K7X5EM1kR2JY90BNKk9lo6Usq1T/nWFdT7TQ=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], "@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=="], "@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=="],
@@ -223,7 +246,55 @@
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@pinia/colada": ["@pinia/colada@0.21.6", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-DppfAYky3Uavlpdx2iZHgd/+ZVPyBGTR+x+kFfAUz8h9l1DIQgf2cw/QZg0RZ4GAUNnKf6Ue6FzfWttwqhZXUQ=="], "@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=="],
"@oxc-parser/binding-android-arm-eabi": ["@oxc-parser/binding-android-arm-eabi@0.115.0", "", { "os": "android", "cpu": "arm" }, "sha512-VoB2rhgoqgYf64d6Qs5emONQW8ASiTc0xp+aUE4JUhxjX+0pE3gblTYDO0upcN5vt9UlBNmUhAwfSifkfre7nw=="],
"@oxc-parser/binding-android-arm64": ["@oxc-parser/binding-android-arm64@0.115.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lWRX75u+gqfB4TF3pWCHuvhaeneAmRl2b2qNBcl4S6yJ0HtnT4VXOMEZrq747i4Zby1ZTxj6mtOe678Bg8gRLw=="],
"@oxc-parser/binding-darwin-arm64": ["@oxc-parser/binding-darwin-arm64@0.115.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ii/oOZjfGY1aszXTy29Z5DRyCEnBOrAXDVCvfdfXFQsOZlbbOa7NMHD7D+06YFe5qdxfmbWAYv4yn6QJi/0d2g=="],
"@oxc-parser/binding-darwin-x64": ["@oxc-parser/binding-darwin-x64@0.115.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-R/sW/p8l77wglbjpMcF+h/3rWbp9zk1mRP3U14mxTYIC2k3m+aLBpXXgk2zksqf9qKk5mcc4GIYsuCn9l8TgDg=="],
"@oxc-parser/binding-freebsd-x64": ["@oxc-parser/binding-freebsd-x64@0.115.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-CSJ5ldNm9wIGGkhaIJeGmxRMZbgxThRN+X1ufYQQUNi5jZDV/U3C2QDMywpP93fczNBj961hXtcUPO/oVGq4Pw=="],
"@oxc-parser/binding-linux-arm-gnueabihf": ["@oxc-parser/binding-linux-arm-gnueabihf@0.115.0", "", { "os": "linux", "cpu": "arm" }, "sha512-uWFwssE5dHfQ8lH+ktrsD9JA49+Qa0gtxZHUs62z1e91NgGz6O7jefHGI6aygNyKNS45pnnBSDSP/zV977MsOQ=="],
"@oxc-parser/binding-linux-arm-musleabihf": ["@oxc-parser/binding-linux-arm-musleabihf@0.115.0", "", { "os": "linux", "cpu": "arm" }, "sha512-fZbqt8y/sKQ+v6bBCuv/mYYFoC0+fZI3mGDDEemmDOhT78+aUs2+4ZMdbd2btlXmnLaScl37r8IRbhnok5Ka9w=="],
"@oxc-parser/binding-linux-arm64-gnu": ["@oxc-parser/binding-linux-arm64-gnu@0.115.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-1ej/MjuTY9tJEunU/hUPIFmgH5PqgMQoRjNOvOkibtJ3Zqlw/+Lc+HGHDNET8sjbgIkWzdhX+p4J96A5CPdbag=="],
"@oxc-parser/binding-linux-arm64-musl": ["@oxc-parser/binding-linux-arm64-musl@0.115.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-HjsZbJPH9mMd4swJRywVMsDZsJX0hyKb1iNHo5ijRl5yhtbO3lj7ImSrrL1oZ1VEg0te4iKmDGGz/6YPLd1G8w=="],
"@oxc-parser/binding-linux-ppc64-gnu": ["@oxc-parser/binding-linux-ppc64-gnu@0.115.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-zhhePoBrd7kQx3oClX/W6NldsuCbuMqaN9rRsY+6/WoorAb4j490PG/FjqgAXscWp2uSW2WV9L+ksn0wHrvsrg=="],
"@oxc-parser/binding-linux-riscv64-gnu": ["@oxc-parser/binding-linux-riscv64-gnu@0.115.0", "", { "os": "linux", "cpu": "none" }, "sha512-t/IRojvUE9XrKu+/H1b8YINug+7Q6FLls5rsm2lxB5mnS8GN/eYAYrPgHkcg9/1SueRDSzGpDYu3lGWTObk1zw=="],
"@oxc-parser/binding-linux-riscv64-musl": ["@oxc-parser/binding-linux-riscv64-musl@0.115.0", "", { "os": "linux", "cpu": "none" }, "sha512-79jBHSSh/YpQRAmvYoaCfpyToRbJ/HBrdB7hxK2ku2JMehjopTVo+xMJss/RV7/ZYqeezgjvKDQzapJbgcjVZA=="],
"@oxc-parser/binding-linux-s390x-gnu": ["@oxc-parser/binding-linux-s390x-gnu@0.115.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-nA1TpxkhNTIOMMyiSSsa7XIVJVoOU/SsVrHIz3gHvWweB5PHCQfO7w+Lb2EP0lBWokv7HtA/KbF7aLDoXzmuMw=="],
"@oxc-parser/binding-linux-x64-gnu": ["@oxc-parser/binding-linux-x64-gnu@0.115.0", "", { "os": "linux", "cpu": "x64" }, "sha512-9iVX789DoC3SaOOG+X6NcF/tVChgLp2vcHffzOC2/Z1JTPlz6bMG2ogvcW6/9s0BG2qvhNQImd+gbWYeQbOwVw=="],
"@oxc-parser/binding-linux-x64-musl": ["@oxc-parser/binding-linux-x64-musl@0.115.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RmQmk+mjCB0nMNfEYhaCxwofLo1Z95ebHw1AGvRiWGCd4zhCNOyskgCbMogIcQzSB3SuEKWgkssyaiQYVAA4hQ=="],
"@oxc-parser/binding-openharmony-arm64": ["@oxc-parser/binding-openharmony-arm64@0.115.0", "", { "os": "none", "cpu": "arm64" }, "sha512-viigraWWQhhDvX5aGq+wrQq58k00Xq3MHz/0R4AFMxGlZ8ogNonpEfNc73Q5Ly87Z6sU9BvxEdG0dnYTfVnmew=="],
"@oxc-parser/binding-wasm32-wasi": ["@oxc-parser/binding-wasm32-wasi@0.115.0", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-IzGCrMwXhpb4kTXy/8lnqqqwjI7eOvy+r9AhVw+hsr8t1ecBBEHprcNy0aKatFHN6hsX7UMHHQmBAQjVvL/p1A=="],
"@oxc-parser/binding-win32-arm64-msvc": ["@oxc-parser/binding-win32-arm64-msvc@0.115.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-/ym+Absk/TLFvbhh3se9XYuI1D7BrUVHw4RaG/2dmWKgBenrZHaJsgnRb7NJtaOyjEOLIPtULx1wDdVL0SX2eg=="],
"@oxc-parser/binding-win32-ia32-msvc": ["@oxc-parser/binding-win32-ia32-msvc@0.115.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-AQSZjIR+b+Te7uaO/hGTMjT8/oxlYrvKrOTi4KTHF/O6osjHEatUQ3y6ZW2+8+lJxy20zIcGz6iQFmFq/qDKkg=="],
"@oxc-parser/binding-win32-x64-msvc": ["@oxc-parser/binding-win32-x64-msvc@0.115.0", "", { "os": "win32", "cpu": "x64" }, "sha512-oxUl82N+fIO9jIaXPph8SPPHQXrA08BHokBBJW8ct9F/x6o6bZE6eUAhUtWajbtvFhL8UYcCWRMba+kww6MBlA=="],
"@oxc-project/runtime": ["@oxc-project/runtime@0.115.0", "", {}, "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ=="],
"@oxc-project/types": ["@oxc-project/types@0.115.0", "", {}, "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw=="],
"@pinia/colada": ["@pinia/colada@0.21.7", "", { "peerDependencies": { "pinia": "^2.2.6 || ^3.0.0", "vue": "^3.5.17" } }, "sha512-b8dJgRSjh7o6NnPXuvMbqv6JhoD/m/CwdadKl5SQvygsbUveYCBoqtnWzPch8AEW/UK0I3rFoATE8WrfI2cgKA=="],
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
@@ -235,111 +306,89 @@
"@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="], "@quansync/fs": ["@quansync/fs@1.0.0", "", { "dependencies": { "quansync": "^1.0.0" } }, "sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.6", "", { "os": "android", "cpu": "arm64" }, "sha512-kvjTSWGcrv+BaR2vge57rsKiYdVR8V8CoS0vgKrc570qRBfty4bT+1X0z3j2TaVV+kAYzA0PjeB9+mdZyqUZlg=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-+tJhD21KvGNtUrpLXrZQlT+j5HZKiEwR2qtcZb3vNOUpvoT9QjEykr75ZW/Kr0W89gose/HVXU6351uVZD8Qvw=="],
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.0.0-rc.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-DKNhjMk38FAWaHwUt1dFR3rA/qRAvn2NUvSG2UGvxvlMxSmN/qqww/j4ABAbXhNRXtGQNmrAINMXRuwHl16ZHg=="],
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.0.0-rc.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8TThsRkCPAnfyMBShxrGdtoOE6h36QepqRQI97iFaQSCRbHFWHcDHppcojZnzXoruuhPnjMEygzaykvPVJsMRg=="],
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm" }, "sha512-ZfmFoOwPUZCWtGOVC9/qbQzfc0249FrRUOzV2XabSMUV60Crp211OWLQN1zmQAsRIVWRcEwhJ46Z1mXGo/L/nQ=="],
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZsGzbNETxPodGlLTYHaCSGVhNN/rvkMDCJYHdT7PZr5jFJRmBfmDi2awhF64Dt2vxrJqY6VeeYSgOzEbHRsb7Q=="],
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-elPpdevtCdUOqziemR86C4CSCr/5sUxalzDrf/CJdMT+kZt2C556as++qHikNOz0vuFf52h+GJNXZM08eWgGPQ=="],
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-IBwXsf56o3xhzAyaZxdM1CX8UFiBEUFCjiVUgny67Q8vPIqkjzJj0YKhd3TbBHanuxThgBa59f6Pgutg2OGk5A=="],
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.0.0-rc.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vOk7G8V9Zm+8a6PL6JTpCea61q491oYlGtO6CvnsbhNLlKdf0bbCPytFzGQhYmCKZDKkEbmnkcIprTEGCURnwg=="],
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.0.0-rc.6", "", { "os": "none", "cpu": "arm64" }, "sha512-ASjEDI4MRv7XCQb2JVaBzfEYO98JKCGrAgoW6M03fJzH/ilCnC43Mb3ptB9q/lzsaahoJyIBoAGKAYEjUvpyvQ=="],
"@rolldown/binding-wasm32-wasi": ["@rolldown/binding-wasm32-wasi@1.0.0-rc.6", "", { "dependencies": { "@napi-rs/wasm-runtime": "^1.1.1" }, "cpu": "none" }, "sha512-mYa1+h2l6Zc0LvmwUh0oXKKYihnw/1WC73vTqw+IgtfEtv47A+rWzzcWwVDkW73+UDr0d/Ie/HRXoaOY22pQDw=="],
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-e2ABskbNH3MRUBMjgxaMjYIw11DSwjLJxBII3UgpF6WClGLIh8A20kamc+FKH5vIaFVnYQInmcLYSUVpqMPLow=="],
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.0.0-rc.6", "", { "os": "win32", "cpu": "x64" }, "sha512-dJVc3ifhaRXxIEh1xowLohzFrlQXkJ66LepHm+CmSprTWgVrPa8Fx3OL57xwIqDEH9hufcKkDX2v65rS3NZyRA=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.2", "", {}, "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="],
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="],
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="],
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="],
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="],
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="],
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="],
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="],
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="],
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="],
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="],
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="],
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="],
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="],
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="],
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="],
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="],
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="],
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="],
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="],
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="],
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="],
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="],
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="],
"@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="],
"@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/node": ["@types/node@25.3.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-hj9YIJimBCipHVfHKRMnvmHg+wfhKc0o4mTtXh9pKBjC8TLJzz0nzGmLi5UJsYAUgSvXFHgb0V2oY10DUFtImw=="], "@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=="], "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="],
"@unhead/vue": ["@unhead/vue@2.1.9", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.9" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-7SqqDEn5zFID1PnEdjLCLa/kOhoAlzol0JdYfVr2Ejek+H4ON4s8iyExv2QQ8bReMosbXQ/Bw41j2CF1NUuGSA=="], "@unhead/vue": ["@unhead/vue@2.1.10", "", { "dependencies": { "hookable": "^6.0.1", "unhead": "2.1.10" }, "peerDependencies": { "vue": ">=3.5.18" } }, "sha512-VP78Onh2HNezLPfhYjfHqn4dxlcQsE6PJgTTs61NksO/thvilNswtgBq0N0MWCLtn43N5akEPGW2y2zxM3PWgQ=="],
"@unocss/cli": ["@unocss/cli@66.6.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.2", "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2", "@unocss/preset-wind4": "66.6.2", "@unocss/transformer-directives": "66.6.2", "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-N7nKnOJ/36FRs3PE7+CFbzg7UBhIsucYYAK5xjJScX0H2q8O6rODaNM5uvc77Qh4q+y1S/Bt5ArOwIewzdpP4w=="], "@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/config": ["@unocss/config@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "colorette": "^2.0.20", "consola": "^3.4.2", "unconfig": "^7.5.0" } }, "sha512-qny2bRW1OA+MZbWShVZdBg6fJundm1LqQwCxJnIpeK3McpPHS3pnHBiwD1wfZHY2z5Pe+XgZOZkozNmG/eyyqg=="], "@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/core": ["@unocss/core@66.6.2", "", {}, "sha512-IOvN1BLRP0VTjjS5afSxmXhvKRDko2Shisp8spU+A9qiH1tXEFP3phyVevm/SuGwBHO1lC+SJ451/4oFkCAwJA=="], "@unocss/core": ["@unocss/core@66.6.5", "", {}, "sha512-hzjo+0EF+pNbf+tb0OjRNZRF9BJoKECcZZgtufxRPpWJdlv+aYmNkH1p9fldlHHzYcn3ZqVnnHnmk7HwaolJbg=="],
"@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-D2tK/8QClrVViSuoH5eLjXwlVOK1UgXx7ukz/D260+R6vhCmjv97RXPouZkq40sxGzfxzaQZUyPEjXLjtnO3bw=="], "@unocss/extractor-arbitrary-variants": ["@unocss/extractor-arbitrary-variants@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-wqzRtbyy3I595WCwwb8VBmznJTHWcTdylzVT+WBgacJDjRlT1sXaq2fRlOsHvtTRj1qG70t3PwKc6XgU0hutNg=="],
"@unocss/inspector": ["@unocss/inspector@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2", "colorette": "^2.0.20", "gzip-size": "^6.0.0", "sirv": "^3.0.2" } }, "sha512-q0kktb01dXeeXyNnNwYM1SkSHxrEOQhCZ/YQ5aCdC7BWNGF4yZMK0YrJXmGUTEHN4RhEPLN/rAIsDBsKcoFaAQ=="], "@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/preset-attributify": ["@unocss/preset-attributify@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-pRry38qO1kJvj5/cekbDk0QLosty+UFQ3fhNiph88D//jkT5tsUCn77nB/RTSe7oTqw/FqNwxPgbGz/wfNWqZg=="], "@unocss/preset-attributify": ["@unocss/preset-attributify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-fx+pKMZ0WgT+dfinVaLkNXlx6oZFwtMbZj5O/1SQia0UcfhnyS+G35HYpbgoc9GEAl3DclxxotzZjveZm++9fA=="],
"@unocss/preset-icons": ["@unocss/preset-icons@66.6.2", "", { "dependencies": { "@iconify/utils": "^3.1.0", "@unocss/core": "66.6.2", "ofetch": "^1.5.1" } }, "sha512-FjhxvYX+21HefYdMIxJCq8C9v/K7fSlO1DMqDQgtrCp0/WvHyFncHILLOwp064M7m3AqzOVJx7Vw/zCvKy0Jrg=="], "@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-mini": ["@unocss/preset-mini@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/extractor-arbitrary-variants": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-mybpiAq9htF7PWPH1Mnb4y7hrxVwpsBg8VfbjSglY3SfLca8RrJtvBT+DVh7YUDRiYsZGfihRWkfD0AN68gkcA=="], "@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-tagify": ["@unocss/preset-tagify@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-ybb45So2x87P3bssLRp1uIS+VHAeNSecwkHqiv93PnuBDJ38/9XlqWF98uga2MEfNM3zvMj9plX9MauidxiPrw=="], "@unocss/preset-tagify": ["@unocss/preset-tagify@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-YYk/eg1OWX4Nx7rK1YZLMHXXntzNRDHp6BIInJteQmlXw0sFgrtdMKj7fnxrORsBDHwxWMp4sWEucPvfCtTlVQ=="],
"@unocss/preset-typography": ["@unocss/preset-typography@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-1f/ZfeuLQOnO48mRz1+6UdoJxa13ZYcamaLz7ft96n7D1eWvkOUAC/AUUke/kbHh3vvqwRVimC9OpdXxdGFQAQ=="], "@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-uno": ["@unocss/preset-uno@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2" } }, "sha512-Wy3V25ZF29OmVHJk5ghP6HCCRNBJXm0t+bKLKJJknOjD+/D51DZbUsDqZBtTpVtgi/SOPDbw7cX3lY2oqt4Hnw=="], "@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-web-fonts": ["@unocss/preset-web-fonts@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "ofetch": "^1.5.1" } }, "sha512-0ckqiE8HkhETeghhxCXVGf96sNPhgBsB5q32iAuMM0HFR4x+ANiLqyfKrm/iqxKUw6rVO4+ItTV0RUWKcZvkXg=="], "@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-wind": ["@unocss/preset-wind@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-wind3": "66.6.2" } }, "sha512-G0H4baUizmTByEowqGuYbKpU2TTisDhZ9W7hrIpYFbRkFv0i1kN2mIxCwj/FLmdY/6x8iSRJ7rO8Nez63YYhnw=="], "@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-wind3": ["@unocss/preset-wind3@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/preset-mini": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-UqdU2Obx3wXid9xeBHGY1MWxedXa43MGuP5Z2FA9modcXptReux4Zhy764SeQwx6acOUEql2/CTvOBwelZzheQ=="], "@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-wind4": ["@unocss/preset-wind4@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/extractor-arbitrary-variants": "66.6.2", "@unocss/rule-utils": "66.6.2" } }, "sha512-XU+4NN9QIMefawDB9FqOeKONXeGDUJQuQgOeBcpbV/jwOYtyqRrHiqQg++fy1hRbluM+S+KqwRHYjvje8zCTow=="], "@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/rule-utils": ["@unocss/rule-utils@66.6.2", "", { "dependencies": { "@unocss/core": "^66.6.2", "magic-string": "^0.30.21" } }, "sha512-cygfCtkeMrqMM6si1cnyOF16sS7M2gCAqgmZybAhGV7tmH7V8Izn52JZiZIrxVRNMz9dWMVWerHEI9nLbFdbrg=="], "@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/transformer-attributify-jsx": ["@unocss/transformer-attributify-jsx@66.6.2", "", { "dependencies": { "@babel/parser": "7.27.7", "@babel/traverse": "7.27.7", "@unocss/core": "66.6.2" } }, "sha512-WiAEdEowGjQWu1ayhkGGBNGyw3mZLzZ+V5o3zx5U2GPuqvP67YIUfvY+/gTkCnd4+A8unkb+a1VeVgr4cHUkQw=="], "@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-compile-class": ["@unocss/transformer-compile-class@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-L0yaQAmvWkm6LVLXMviqhHIi4c7WQpZFBgJF8jfsALyHihh8K9U9OrRJ81zfLH3Ltw5ZbGzoDE8m/2bB6aRhyw=="], "@unocss/transformer-compile-class": ["@unocss/transformer-compile-class@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-U/ukk5lyZOFNyz9hVzZBkxciayjgimyfPuQBa5PHSC4W3nDmnFd1zgXzUVaM6KduPmiTExzpJSDgELb2OTbpqg=="],
"@unocss/transformer-directives": ["@unocss/transformer-directives@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2", "@unocss/rule-utils": "66.6.2", "css-tree": "^3.1.0" } }, "sha512-gjLDLItTUJ4CV8K2AA0cw381a7rJ3U4kCHQmZmN3+956o2R7cEHSLyEczmMy04Mg2JBomrjIZjo+L66z5rvblQ=="], "@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-variant-group": ["@unocss/transformer-variant-group@66.6.2", "", { "dependencies": { "@unocss/core": "66.6.2" } }, "sha512-Uoo6xthOHJ36NdN4b7s/Y7R3fZOf4JYgKzuldHEyHAo0LL204Ss+Ah0+TEt4v72aq+Z86vrLJPyYCeGNKdr8cA=="], "@unocss/transformer-variant-group": ["@unocss/transformer-variant-group@66.6.5", "", { "dependencies": { "@unocss/core": "66.6.5" } }, "sha512-k6vQgn/P7ObHBRYw6o1+xwdQIfwc6b9O5TFFe87UmBB6hJ2zaHWRVuPB6oky7F9Gz8bPfXC3WJuv7UyIwRmBQQ=="],
"@unocss/vite": ["@unocss/vite@66.6.2", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "@unocss/config": "66.6.2", "@unocss/core": "66.6.2", "@unocss/inspector": "66.6.2", "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-HLmzDvde3BJ2C6iromHVE21lmNm4SmGSMlbSbFuLPOmWV11XhhHBkAOzytSxPBRG0dbuo+InSGUM14Ek2d6UDg=="], "@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=="],
"@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=="], "@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=="],
@@ -383,6 +432,10 @@
"@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="], "@vueuse/shared": ["@vueuse/shared@14.2.1", "", { "peerDependencies": { "vue": "^3.5.0" } }, "sha512-shTJncjV9JTI4oVNyF1FQonetYAiTBd+Qj7cY89SWbXSkx7gyhrgtEdF2ZAVWS1S3SHlaROO6F2IesJxQEkZBw=="],
"@whatwg-node/fetch": ["@whatwg-node/fetch@0.9.23", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.6.0", "urlpattern-polyfill": "^10.0.0" } }, "sha512-7xlqWel9JsmxahJnYVUj/LLxWcnA93DR4c9xlw3U814jWTiYalryiH1qToik1hOxweKKRLi4haXHM5ycRksPBA=="],
"@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.6.0", "", { "dependencies": { "@kamilkisiela/fast-url-parser": "^1.1.4", "busboy": "^1.6.0", "fast-querystring": "^1.1.1", "tslib": "^2.6.3" } }, "sha512-tcZAhrpx6oVlkEsRngeTEEE7I5/QdLjeEz4IlekabGaESP7+Dkm/6a9KcF1KdCBB7mO9PXtBkwCuTCt8+UPg8Q=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], "acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="], "ast-kit": ["ast-kit@2.2.0", "", { "dependencies": { "@babel/parser": "^7.28.5", "pathe": "^2.0.3" } }, "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw=="],
@@ -399,6 +452,8 @@
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="], "caniuse-lite": ["caniuse-lite@1.0.30001774", "", {}, "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA=="],
@@ -419,6 +474,8 @@
"copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], "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=="],
"css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
@@ -449,20 +506,28 @@
"exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"globals": ["globals@11.12.0", "", {}, "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA=="],
"gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="], "gzip-size": ["gzip-size@6.0.0", "", { "dependencies": { "duplexer": "^0.1.2" } }, "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q=="],
"hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], "hono": ["hono@4.12.5", "", {}, "sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg=="],
"hookable": ["hookable@6.0.1", "", {}, "sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw=="], "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-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-mobile": ["is-mobile@5.0.0", "", {}, "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ=="], "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=="], "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="],
@@ -477,17 +542,47 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"lightningcss": ["lightningcss@1.31.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.31.1", "lightningcss-darwin-arm64": "1.31.1", "lightningcss-darwin-x64": "1.31.1", "lightningcss-freebsd-x64": "1.31.1", "lightningcss-linux-arm-gnueabihf": "1.31.1", "lightningcss-linux-arm64-gnu": "1.31.1", "lightningcss-linux-arm64-musl": "1.31.1", "lightningcss-linux-x64-gnu": "1.31.1", "lightningcss-linux-x64-musl": "1.31.1", "lightningcss-win32-arm64-msvc": "1.31.1", "lightningcss-win32-x64-msvc": "1.31.1" } }, "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.31.1", "", { "os": "android", "cpu": "arm64" }, "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.31.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg=="],
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.31.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA=="],
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.31.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A=="],
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.31.1", "", { "os": "linux", "cpu": "arm" }, "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g=="],
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg=="],
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.31.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg=="],
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA=="],
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.31.1", "", { "os": "linux", "cpu": "x64" }, "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA=="],
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.31.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w=="],
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.31.1", "", { "os": "win32", "cpu": "x64" }, "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw=="],
"local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="], "local-pkg": ["local-pkg@1.1.2", "", { "dependencies": { "mlly": "^1.7.4", "pkg-types": "^2.3.0", "quansync": "^0.2.11" } }, "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A=="],
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "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=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="], "magic-string-ast": ["magic-string-ast@1.0.3", "", { "dependencies": { "magic-string": "^0.30.19" } }, "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA=="],
"mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="],
"miniflare": ["miniflare@4.20260302.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260302.0", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-joGFywlo7HdfHXXGOkc6tDCVkwjEncM0mwEsMOLWcl+vDVJPj9HRV7JtEa0+lCpNOLdYw7mZNHYe12xz9KtJOw=="], "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"miniflare": ["miniflare@4.20260301.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.18.2", "workerd": "1.20260301.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-fqkHx0QMKswRH9uqQQQOU/RoaS3Wjckxy3CUX3YGJr0ZIMu7ObvI+NovdYi6RIsSPthNtq+3TPmRNxjeRiasog=="],
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
@@ -501,6 +596,8 @@
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
"node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="],
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
@@ -509,6 +606,10 @@
"ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="], "ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"oxc-parser": ["oxc-parser@0.115.0", "", { "dependencies": { "@oxc-project/types": "^0.115.0" }, "optionalDependencies": { "@oxc-parser/binding-android-arm-eabi": "0.115.0", "@oxc-parser/binding-android-arm64": "0.115.0", "@oxc-parser/binding-darwin-arm64": "0.115.0", "@oxc-parser/binding-darwin-x64": "0.115.0", "@oxc-parser/binding-freebsd-x64": "0.115.0", "@oxc-parser/binding-linux-arm-gnueabihf": "0.115.0", "@oxc-parser/binding-linux-arm-musleabihf": "0.115.0", "@oxc-parser/binding-linux-arm64-gnu": "0.115.0", "@oxc-parser/binding-linux-arm64-musl": "0.115.0", "@oxc-parser/binding-linux-ppc64-gnu": "0.115.0", "@oxc-parser/binding-linux-riscv64-gnu": "0.115.0", "@oxc-parser/binding-linux-riscv64-musl": "0.115.0", "@oxc-parser/binding-linux-s390x-gnu": "0.115.0", "@oxc-parser/binding-linux-x64-gnu": "0.115.0", "@oxc-parser/binding-linux-x64-musl": "0.115.0", "@oxc-parser/binding-openharmony-arm64": "0.115.0", "@oxc-parser/binding-wasm32-wasi": "0.115.0", "@oxc-parser/binding-win32-arm64-msvc": "0.115.0", "@oxc-parser/binding-win32-ia32-msvc": "0.115.0", "@oxc-parser/binding-win32-x64-msvc": "0.115.0" } }, "sha512-2w7Xn3CbS/zwzSY82S5WLemrRu3CT57uF7Lx8llrE/2bul6iMTcJE4Rbls7GDNbLn3ttATI68PfOz2Pt3KZ2cQ=="],
"oxc-walker": ["oxc-walker@0.7.0", "", { "dependencies": { "magic-regexp": "^0.10.0" }, "peerDependencies": { "oxc-parser": ">=0.98.0" } }, "sha512-54B4KUhrzbzc4sKvKwVYm7E2PgeROpGba0/2nlNZMqfDyca+yOor5IMb4WLGBatGDT0nkzYdYuzylg7n3YfB7A=="],
"package-manager-detector": ["package-manager-detector@1.6.0", "", {}, "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="], "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=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="],
@@ -531,9 +632,11 @@
"readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], "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=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], "rolldown": ["rolldown@1.0.0-rc.6", "", { "dependencies": { "@oxc-project/types": "=0.115.0", "@rolldown/pluginutils": "1.0.0-rc.6" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-arm64": "1.0.0-rc.6", "@rolldown/binding-darwin-x64": "1.0.0-rc.6", "@rolldown/binding-freebsd-x64": "1.0.0-rc.6", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.6", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.6", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.6", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.6", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.6", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.6", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.6" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-B8vFPV1ADyegoYfhg+E7RAucYKv0xdVlwYYsIJgfPNeiSxZGWNxts9RqhyGzC11ULK/VaeXyKezGCwpMiH8Ktw=="],
"scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="], "scule": ["scule@1.3.0", "", {}, "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g=="],
@@ -547,6 +650,8 @@
"speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
"strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="], "strip-literal": ["strip-literal@3.1.0", "", { "dependencies": { "js-tokens": "^9.0.1" } }, "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg=="],
"superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="],
@@ -561,8 +666,12 @@
"totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="], "totalist": ["totalist@3.0.1", "", {}, "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ=="],
"tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"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=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="],
"unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="], "unconfig": ["unconfig@7.5.0", "", { "dependencies": { "@quansync/fs": "^1.0.0", "defu": "^6.1.4", "jiti": "^2.6.1", "quansync": "^1.0.0", "unconfig-core": "7.5.0" } }, "sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA=="],
@@ -575,11 +684,11 @@
"unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="],
"unhead": ["unhead@2.1.9", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-4GvP6YeJQzo9J3g9fFZUJOH6jacUp5JgJ0/zC8eZrt8Dwompg9SuOSfrYbZaEzsfMPgQc4fsEjMoY9WzGPOChg=="], "unhead": ["unhead@2.1.10", "", { "dependencies": { "hookable": "^6.0.1" } }, "sha512-We8l9uNF8zz6U8lfQaVG70+R/QBfQx1oPIgXin4BtZnK2IQpz6yazQ0qjMNVBDw2ADgF2ea58BtvSK+XX5AS7g=="],
"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=="], "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.2", "", { "dependencies": { "@unocss/cli": "66.6.2", "@unocss/core": "66.6.2", "@unocss/preset-attributify": "66.6.2", "@unocss/preset-icons": "66.6.2", "@unocss/preset-mini": "66.6.2", "@unocss/preset-tagify": "66.6.2", "@unocss/preset-typography": "66.6.2", "@unocss/preset-uno": "66.6.2", "@unocss/preset-web-fonts": "66.6.2", "@unocss/preset-wind": "66.6.2", "@unocss/preset-wind3": "66.6.2", "@unocss/preset-wind4": "66.6.2", "@unocss/transformer-attributify-jsx": "66.6.2", "@unocss/transformer-compile-class": "66.6.2", "@unocss/transformer-directives": "66.6.2", "@unocss/transformer-variant-group": "66.6.2", "@unocss/vite": "66.6.2" }, "peerDependencies": { "@unocss/astro": "66.6.2", "@unocss/postcss": "66.6.2", "@unocss/webpack": "66.6.2" }, "optionalPeers": ["@unocss/astro", "@unocss/postcss", "@unocss/webpack"] }, "sha512-ulkfFBFm++/yTdgDn/clpxtm3GxynZi57F4KETQkMQWRXUI7FwqPKGn0xooscvbtldlX67pkovwj/mzkwExitQ=="], "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=="],
"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=="], "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=="],
@@ -591,7 +700,9 @@
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
"vite": ["vite@8.0.0-beta.16", "", { "dependencies": { "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.31.1", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rolldown": "1.0.0-rc.6", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.0.0-alpha.31", "esbuild": "^0.27.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-c0t7hYkxsjws89HH+BUFh/sL3BpPNhNsL9CJrTpMxBmwKQBRSa5OJ5w4o9O0bQVI/H/vx7UpUUIevvXa37NS/Q=="],
"vite-ssr-components": ["vite-ssr-components@0.5.2", "", { "dependencies": { "@babel/parser": "^7.27.2", "@babel/traverse": "^7.27.1", "picomatch": "^4.0.2" } }, "sha512-1a8YThRwyyu1gGjc1Ral9Q4uS+n0D4GydhbkVd9c1SA1YNgXyrOizttped87C1ItEznQzhiCyQjaOcYnXa0zMA=="], "vite-ssr-components": ["vite-ssr-components@0.5.2", "", { "dependencies": { "@babel/parser": "^7.27.2", "@babel/traverse": "^7.27.1", "picomatch": "^4.0.2" } }, "sha512-1a8YThRwyyu1gGjc1Ral9Q4uS+n0D4GydhbkVd9c1SA1YNgXyrOizttped87C1ItEznQzhiCyQjaOcYnXa0zMA=="],
@@ -599,11 +710,15 @@
"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=="], "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=="],
"webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
"webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="],
"workerd": ["workerd@1.20260302.0", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260302.0", "@cloudflare/workerd-darwin-arm64": "1.20260302.0", "@cloudflare/workerd-linux-64": "1.20260302.0", "@cloudflare/workerd-linux-arm64": "1.20260302.0", "@cloudflare/workerd-windows-64": "1.20260302.0" }, "bin": { "workerd": "bin/workerd" } }, "sha512-FhNdC8cenMDllI6bTktFgxP5Bn5ZEnGtofgKipY6pW9jtq708D1DeGI6vGad78KQLBGaDwFy1eThjCoLYgFfog=="], "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
"wrangler": ["wrangler@4.68.1", "", { "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.20260302.0", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260302.0" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260302.0" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-G+TI3k/olEGBAVkPtUlhAX/DIbL/190fv3aK+r+45/wPclNEymjxCc35T8QGTDhc2fEMXiw51L5bH9aNsBg+yQ=="], "workerd": ["workerd@1.20260301.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260301.1", "@cloudflare/workerd-darwin-arm64": "1.20260301.1", "@cloudflare/workerd-linux-64": "1.20260301.1", "@cloudflare/workerd-linux-arm64": "1.20260301.1", "@cloudflare/workerd-windows-64": "1.20260301.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-oterQ1IFd3h7PjCfT4znSFOkJCvNQ6YMOyZ40YsnO3nrSpgB4TbJVYWFOnyJAw71/RQuupfVqZZWKvsy8GO3fw=="],
"wrangler": ["wrangler@4.70.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.4.2", "@cloudflare/unenv-preset": "2.14.0", "blake3-wasm": "2.1.5", "esbuild": "0.27.3", "miniflare": "4.20260301.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260301.1" }, "optionalDependencies": { "fsevents": "~2.3.2" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20260226.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-PNDZ9o4e+B5x+1bUbz62Hmwz6G9lw+I9pnYe/AguLddJFjfIyt2cmFOUOb3eOZSoXsrhcEPUg2YidYIbVwUkfw=="],
"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=="], "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=="],
@@ -621,9 +736,7 @@
"@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="], "@quansync/fs/quansync": ["quansync@1.0.0", "", {}, "sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA=="],
"@unocss/transformer-attributify-jsx/@babel/parser": ["@babel/parser@7.27.7", "", { "dependencies": { "@babel/types": "^7.27.7" }, "bin": "./bin/babel-parser.js" }, "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q=="], "@unocss/config/@unocss/core": ["@unocss/core@66.6.4", "", {}, "sha512-Fii3lhVJVFrKUz6hMGAkq3sXBfNnXB2G8bldNHuBHJpDAoP1F0oO/SU/oSqSjCYvtcD5RtOn8qwzcHuuN3B/mg=="],
"@unocss/transformer-attributify-jsx/@babel/traverse": ["@babel/traverse@7.27.7", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", "@babel/types": "^7.27.7", "debug": "^4.3.1", "globals": "^11.1.0" } }, "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw=="],
"@vitejs/plugin-vue-jsx/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="], "@vitejs/plugin-vue-jsx/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.5", "", {}, "sha512-RxlLX/DPoarZ9PtxVrQgZhPoor987YtKQqCo5zkjX+0S0yLJ7Vv515Wk6+xtTL67VONKJKxETWZwuZjss2idYw=="],
@@ -637,6 +750,8 @@
"mlly/pkg-types": ["pkg-types@1.3.1", "", { "dependencies": { "confbox": "^0.1.8", "mlly": "^1.7.4", "pathe": "^2.0.1" } }, "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ=="], "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=="], "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=="], "strip-literal/js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
@@ -649,8 +764,6 @@
"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-router/unplugin": ["unplugin@3.0.0", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg=="],
"@unocss/transformer-attributify-jsx/@babel/traverse/@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"mlly/pkg-types/confbox": ["confbox@0.1.8", "", {}, "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w=="], "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=="], "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=="],

2
components.d.ts vendored
View File

@@ -24,6 +24,7 @@ declare module 'vue' {
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default'] Bell: typeof import('./src/components/icons/Bell.vue')['default']
@@ -103,6 +104,7 @@ declare global {
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default'] const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default'] const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default'] const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default'] const Bell: typeof import('./src/components/icons/Bell.vue')['default']

3221
docs.json

File diff suppressed because it is too large Load Diff

BIN
golang.tar.gz Normal file

Binary file not shown.

View File

@@ -10,29 +10,34 @@
"tail": "wrangler tail" "tail": "wrangler tail"
}, },
"dependencies": { "dependencies": {
"@pinia/colada": "^0.21.2", "@hattip/adapter-node": "^0.0.49",
"@unhead/vue": "^2.1.2", "@hono/node-server": "^1.19.11",
"@vueuse/core": "^14.2.0", "@pinia/colada": "^0.21.7",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"aws4fetch": "^1.0.20", "aws4fetch": "^1.0.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"hono": "^4.11.7", "hono": "^4.12.5",
"i18next": "^25.8.14",
"i18next-http-backend": "^3.0.2",
"i18next-vue": "^5.4.0",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"vue": "^3.5.27", "vue": "^3.5.29",
"vue-router": "^5.0.2", "vue-router": "^5.0.3",
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.23.0", "@cloudflare/vite-plugin": "^1.26.0",
"@types/node": "^25.2.0", "@types/node": "^25.3.3",
"@vitejs/plugin-vue": "^6.0.4", "@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4", "@vitejs/plugin-vue-jsx": "^5.1.4",
"unocss": "^66.6.0", "unocss": "^66.6.5",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0", "unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1", "vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2", "vite-ssr-components": "^0.5.2",
"wrangler": "^4.62.0" "wrangler": "^4.70.0"
} }
} }

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
export const customFetch = (url: string, options: RequestInit) => { export const customFetch: typeof fetch = (input, init) => {
return fetch(url, { return fetch(input, {
...options, ...init,
credentials: "include", credentials: 'include',
}); });
} };

View File

@@ -1,31 +1,125 @@
import { tryGetContext } from "hono/context-storage"; import { tryGetContext } from 'hono/context-storage';
export const baseAPIURL = "https://api.pipic.fun";
export const customFetch = (url: string, options: RequestInit) => { // export const baseAPIURL = 'https://api.pipic.fun';
options.credentials = "include"; export const baseAPIURL = 'http://localhost:8080';
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>(); const c = tryGetContext<any>();
if (!c) { if (!c) {
throw new Error("Hono context not found in SSR"); throw new Error('Hono context not found in SSR');
} }
// Merge headers properly - keep original options.headers and add request headers
const reqHeaders = new Headers(c.req.header());
// Remove headers that shouldn't be forwarded
reqHeaders.delete("host");
reqHeaders.delete("connection");
const mergedHeaders: Record<string, string> = {}; const apiUrl = resolveApiUrl(input, c.req.url);
reqHeaders.forEach((value, key) => { const method = resolveMethod(input, options);
mergedHeaders[key] = value; const body = resolveBody(input, options, method.toUpperCase());
}); const requestOptions: RequestInit & { duplex?: 'half' } = {
options.headers = { ...(isRequestLikeOptions(options) ? {} : options),
...mergedHeaders, method,
...(options.headers as Record<string, string>), headers: mergeHeaders(input, options),
body,
credentials: getOptionCredentials(options) ?? 'include',
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
}; };
const apiUrl = [baseAPIURL, url.replace(/^r/, "")].join(""); if (body) {
return fetch(apiUrl, options).then(async (res) => { requestOptions.duplex = 'half';
res.headers.getSetCookie()?.forEach((cookie) => { }
c.header("Set-Cookie", cookie);
}); return fetch(apiUrl, requestOptions).then((response) => {
return res; 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;
}); });
}; };

View File

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

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouteLoading } from '@/composables/useRouteLoading'
const { visible, progress } = useRouteLoading()
const barStyle = computed(() => ({
transform: `scaleX(${progress.value / 100})`,
opacity: visible.value ? '1' : '0',
}))
</script>
<template>
<div
class="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-0.75"
aria-hidden="true"
>
<div
class="h-full origin-left rounded-r-full bg-primary/50 shadow-[0_0_12px_var(--colors-primary-DEFAULT)] transition-[transform,opacity] duration-200 ease-out"
:style="barStyle"
/>
</div>
</template>

View File

@@ -5,26 +5,28 @@ import Video from "@/components/icons/Video.vue";
import SettingsIcon from "@/components/icons/SettingsIcon.vue"; import SettingsIcon from "@/components/icons/SettingsIcon.vue";
// import Upload from "@/components/icons/Upload.vue"; // import Upload from "@/components/icons/Upload.vue";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { createStaticVNode, ref } from "vue"; import { computed, createStaticVNode, ref } from "vue";
import { useTranslation } from 'i18next-vue';
import NotificationDrawer from "./NotificationDrawer.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"; const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1); const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>(); const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
const isNotificationOpen = ref(false); const isNotificationOpen = ref(false);
const { t } = useTranslation();
const handleNotificationClick = (event: Event) => { const handleNotificationClick = (event: Event) => {
notificationPopover.value?.toggle(event); notificationPopover.value?.toggle(event);
}; };
const links = [ const links = computed(() => [
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className }, { href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
{ href: "/", label: "Overview", icon: Home, type: "a", className }, { href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className }, // { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
{ href: "/videos", label: "Videos", icon: Video, type: "a", className }, { href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen }, { href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
{ href: "/settings", label: "Settings", icon: SettingsIcon, type: "a", className }, { href: "/settings", label: t('nav.settings'), icon: SettingsIcon, type: "a", className },
]; ]);
//v-tooltip="i.label" //v-tooltip="i.label"
@@ -34,7 +36,7 @@ const links = [
<header <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"> class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
<template v-for="i in links" :key="i.label"> <template v-for="i in links" :key="i.href">
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'" <component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
v-bind="i.type === 'a' ? { to: i.href } : {}" v-bind="i.type === 'a' ? { to: i.href } : {}"
@click="i.action && i.action($event)" @click="i.action && i.action($event)"

View File

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

View File

@@ -1,122 +1,51 @@
<script setup lang="ts"> <script setup lang="ts">
import NotificationItem from '@/routes/notification/components/NotificationItem.vue'; import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { useNotifications } from '@/composables/useNotifications';
import { onClickOutside } from '@vueuse/core'; import { onClickOutside } from '@vueuse/core';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, onMounted, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false); const isMounted = ref(false);
onMounted(() => { onMounted(() => {
isMounted.value = true; isMounted.value = true;
void notificationStore.fetchNotifications();
}); });
// Emit event when visibility changes
const emit = defineEmits(['change']); const emit = defineEmits(['change']);
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
time: string;
read: boolean;
actionUrl?: string;
actionLabel?: string;
}
const visible = ref(false); const visible = ref(false);
const drawerRef = ref(null); const drawerRef = ref(null);
const { t } = useTranslation();
const notificationStore = useNotifications();
// Mock notifications data const unreadCount = computed(() => notificationStore.unreadCount.value);
const notifications = ref<Notification[]>([ const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
{
id: '1',
type: 'video',
title: 'Video processing complete',
message: 'Your video "Summer Vacation 2024" has been successfully processed.',
time: '2 min ago',
read: false,
actionUrl: '/video',
actionLabel: 'View'
},
{
id: '2',
type: 'payment',
title: 'Payment successful',
message: 'Your subscription to Pro Plan has been renewed successfully.',
time: '1 hour ago',
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Receipt'
},
{
id: '3',
type: 'warning',
title: 'Storage almost full',
message: 'You have used 85% of your storage quota.',
time: '3 hours ago',
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Upgrade'
},
{
id: '4',
type: 'success',
title: 'Upload successful',
message: 'Your video "Product Demo v2" has been uploaded.',
time: '1 day ago',
read: true
}
]);
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
const toggle = (event?: Event) => { const toggle = (event?: Event) => {
console.log(event); console.log(event);
// Prevent event propagation to avoid immediate closure by onClickOutside
if (event) {
// We don't stop propagation here to let other listeners work,
// but we might need to ignore the trigger element in onClickOutside
// However, since the trigger is outside this component, simple toggle logic works
// if we use a small delay or ignore ref.
// Best approach: "toggle" usually comes from a button click.
}
visible.value = !visible.value; visible.value = !visible.value;
console.log(visible.value); if (visible.value && !notificationStore.loaded.value) {
void notificationStore.fetchNotifications();
}
}; };
// Handle click outside onClickOutside(drawerRef, () => {
onClickOutside(drawerRef, (event) => {
// We can just set visible to false.
// Note: If the toggle button is clicked, it might toggle it back on immediately
// if the click event propagates.
// The user calls `toggle` from the parent's button click handler.
// If that button is outside `drawerRef` (which it is), this will fire.
// To avoid conflict, we usually check if the target is the trigger.
// But we don't have access to the trigger ref here.
// A common workaround is to use `ignore` option if we had the ref,
// or relying on the fact that if this fires, it sets specific state to false.
// If the button click then fires `toggle`, it might set it true again.
// Optimization: check if visible is true before closing.
if (visible.value) { if (visible.value) {
visible.value = false; visible.value = false;
} }
}, { }, {
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger ignore: ['[name="Notification"]']
}); });
const handleMarkRead = (id: string) => { const handleMarkRead = async (id: string) => {
const notification = notifications.value.find(n => n.id === id); await notificationStore.markRead(id);
if (notification) notification.read = true;
}; };
const handleDelete = (id: string) => { const handleDelete = async (id: string) => {
notifications.value = notifications.value.filter(n => n.id !== id); await notificationStore.deleteNotification(id);
}; };
const handleMarkAllRead = () => { const handleMarkAllRead = async () => {
notifications.value.forEach(n => n.read = true); await notificationStore.markAllRead();
}; };
watch(visible, (val) => { watch(visible, (val) => {
@@ -134,10 +63,9 @@ defineExpose({ toggle });
leave-to-class="opacity-0 -translate-x-4"> leave-to-class="opacity-0 -translate-x-4">
<div v-if="visible" ref="drawerRef" <div v-if="visible" ref="drawerRef"
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3"> class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
<!-- Header -->
<div class="flex items-center justify-between p-4"> <div class="flex items-center justify-between p-4">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900">Notifications</h3> <h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
<span v-if="unreadCount > 0" <span v-if="unreadCount > 0"
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full"> class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
{{ unreadCount }} {{ unreadCount }}
@@ -145,49 +73,44 @@ defineExpose({ toggle });
</div> </div>
<button v-if="unreadCount > 0" @click="handleMarkAllRead" <button v-if="unreadCount > 0" @click="handleMarkAllRead"
class="text-sm text-primary hover:underline font-medium"> class="text-sm text-primary hover:underline font-medium">
Mark all read {{ t('notification.actions.markAllRead') }}
</button> </button>
</div> </div>
<!-- Notification List -->
<div class="flex flex-col flex-1 overflow-y-auto gap-2"> <div class="flex flex-col flex-1 overflow-y-auto gap-2">
<template v-if="notifications.length > 0"> <template v-if="notificationStore.loading.value">
<div v-for="notification in notifications" :key="notification.id" <div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
</div>
</template>
<template v-else-if="mutableNotifications.length > 0">
<div v-for="notification in mutableNotifications" :key="notification.id"
class="border-b border-gray-50 last:border-0"> class="border-b border-gray-50 last:border-0">
<NotificationItem :notification="notification" @mark-read="handleMarkRead" <NotificationItem :notification="notification" @mark-read="handleMarkRead"
@delete="handleDelete" isDrawer /> @delete="handleDelete" isDrawer />
</div> </div>
</template> </template>
<!-- Empty state -->
<div v-else class="py-12 text-center"> <div v-else class="py-12 text-center">
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span> <span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
<p class="text-gray-500 text-sm">No notifications</p> <p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
</div> </div>
</div> </div>
<!-- Footer --> <div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<router-link to="/notification" <router-link to="/notification"
class="block w-full text-center text-sm text-primary font-medium hover:underline" class="block w-full text-center text-sm text-primary font-medium hover:underline"
@click="visible = false"> @click="visible = false">
View all notifications {{ t('notification.actions.viewAll') }}
</router-link> </router-link>
</div> </div>
</div> </div>
</Transition> </Transition>
</Teleport> </Teleport>
</template> </template>
<!-- <style>
.notification-popover {
border-radius: 16px !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
overflow: hidden;
}
.notification-popover .p-popover-content {
padding: 0 !important;
}
</style> -->

View File

@@ -1,3 +1,10 @@
<template> <template>
<ClientOnly>
<AppTopLoadingBar />
</ClientOnly>
<router-view/> <router-view/>
</template> </template>
<script setup lang="ts">
import ClientOnly from '@/components/ClientOnly';
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
</script>

View File

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

View File

@@ -91,7 +91,7 @@ onBeforeUnmount(() => {
type="button" type="button"
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all" class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
@click="dismiss(t.id)" @click="dismiss(t.id)"
aria-label="Dismiss" :aria-label="$t('toast.dismissAria')"
> >
<XIcon class="w-4 h-4" /> <XIcon class="w-4 h-4" />
</button> </button>

View File

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

View File

@@ -1,5 +1,5 @@
<template> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
<path <path
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z" d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"

View File

@@ -5,9 +5,6 @@ defineProps<{
</script> </script>
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 532"><path d="M26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zM138 42c0-9 7-16 16-16h192c9 0 16 7 16 16 0 3 0 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8zm56 267c0 21 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8s-8 4-8 8v16h-5c-24 0-43 19-43 43z" fill="#a6acb9"/><path d="M346 26c9 0 16 7 16 16 0 3-1 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8 0-9 7-16 16-16h192zM126 57l45 86C85 222 10 299 10 427c0 52 43 95 95 95h290c52 0 95-43 95-95 0-128-75-205-161-284l45-86c3-5 4-10 4-15 0-18-14-32-32-32H154c-18 0-32 14-32 32 0 5 1 10 4 15zM26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zm224-185c-4 0-8 4-8 8v16h-5c-24 0-43 19-43 43 0 20 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8z" fill="currentColor"/></svg>
<circle cx="12" cy="12" r="9"/> <svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="532" viewBox="6 -258 500 532"><path d="m379-191-46 81c84 77 163 154 163 279 0 52-43 95-95 95H111c-52 0-95-43-95-95C16 44 96-33 179-110l-46-81c-3-6-5-12-5-19 0-21 17-38 38-38h180c21 0 38 17 38 38 0 7-2 13-5 19zM227-88l-1 1C134-4 64 61 64 169c0 26 21 47 47 47h290c26 0 47-21 47-47C448 61 378-4 286-87l-1-1h-58zm-7-48h72l37-64H183l37 64zm40 96c11 0 20 9 20 20v4h8c11 0 20 9 20 20s-9 20-20 20h-47c-7 0-13 6-13 13 0 6 4 11 10 12l42 7c25 4 44 26 44 52s-19 47-44 51v5c0 11-9 20-20 20s-20-9-20-20v-4h-24c-11 0-20-9-20-20s9-20 20-20h56c6 0 12-5 12-12 0-6-4-12-10-13l-42-7c-25-4-44-26-44-51 0-29 23-53 52-53v-4c0-11 9-20 20-20z" fill="currentColor"/></svg>
<path d="M12 16V8"/>
<path d="M9.5 10a2.5 2.5 0 0 1 5 0v4a2.5 2.5 0 0 1-5 0"/>
</svg>
</template> </template>

View File

@@ -1,10 +1,10 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z" <path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
fill="#a6acb9" /> fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path <path
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z" d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
fill="#1e3050" /> fill="var(--colors-primary-DEFAULT)" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
<path <path

View File

@@ -1,5 +1,5 @@
<template> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="#a6acb9"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="#1e3050"/></svg> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path

View File

@@ -1,9 +1,9 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z" <path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
fill="#a6acb9" /> fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z" <path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
fill="#1e3050" /> fill="var(--colors-primary-DEFAULT)" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
<path <path

View File

@@ -30,12 +30,16 @@ const state = reactive<AppConfirmState>({
}); });
const requireConfirm = (options: AppConfirmOptions) => { const requireConfirm = (options: AppConfirmOptions) => {
const defaultHeader = 'Confirm';
const defaultAccept = 'OK';
const defaultReject = 'Cancel';
state.visible = true; state.visible = true;
state.loading = false; state.loading = false;
state.message = options.message; state.message = options.message;
state.header = options.header ?? 'Confirm'; state.header = options.header ?? defaultHeader;
state.acceptLabel = options.acceptLabel ?? 'OK'; state.acceptLabel = options.acceptLabel ?? defaultAccept;
state.rejectLabel = options.rejectLabel ?? 'Cancel'; state.rejectLabel = options.rejectLabel ?? defaultReject;
state.accept = options.accept; state.accept = options.accept;
state.reject = options.reject; state.reject = options.reject;
}; };

View File

@@ -0,0 +1,128 @@
import { client } from '@/api/client';
import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
export type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
export type AppNotification = {
id: string;
type: NotificationType;
title: string;
message: string;
time: string;
read: boolean;
actionUrl?: string;
actionLabel?: string;
createdAt?: string;
};
type NotificationApiItem = {
id?: string;
type?: string;
title?: string;
message?: string;
read?: boolean;
actionUrl?: string;
actionLabel?: string;
action_url?: string;
action_label?: string;
created_at?: string;
};
const notifications = ref<AppNotification[]>([]);
const loading = ref(false);
const loaded = ref(false);
const normalizeType = (value?: string): NotificationType => {
switch ((value || '').toLowerCase()) {
case 'video':
case 'payment':
case 'warning':
case 'error':
case 'success':
case 'system':
return value as NotificationType;
default:
return 'info';
}
};
export function useNotifications() {
const { t, i18next } = useTranslation();
const formatRelativeTime = (value?: string) => {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const diffMs = Date.now() - date.getTime();
const minutes = Math.max(1, Math.floor(diffMs / 60000));
if (minutes < 60) return t('notification.time.minutesAgo', { count: minutes });
const hours = Math.floor(minutes / 60);
if (hours < 24) return t('notification.time.hoursAgo', { count: hours });
const days = Math.floor(hours / 24);
return t('notification.time.daysAgo', { count: Math.max(1, days) });
};
const mapNotification = (item: NotificationApiItem): AppNotification => ({
id: item.id || '',
type: normalizeType(item.type),
title: item.title || '',
message: item.message || '',
time: formatRelativeTime(item.created_at),
read: Boolean(item.read),
actionUrl: item.actionUrl || item.action_url || undefined,
actionLabel: item.actionLabel || item.action_label || undefined,
createdAt: item.created_at,
});
const fetchNotifications = async () => {
loading.value = true;
try {
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
loaded.value = true;
return notifications.value;
} finally {
loading.value = false;
}
};
const markRead = async (id: string) => {
if (!id) return;
await client.notifications.readCreate(id, { baseUrl: '/r' });
const item = notifications.value.find(notification => notification.id === id);
if (item) item.read = true;
};
const deleteNotification = async (id: string) => {
if (!id) return;
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
notifications.value = notifications.value.filter(notification => notification.id !== id);
};
const markAllRead = async () => {
await client.notifications.readAllCreate({ baseUrl: '/r' });
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
};
const clearAll = async () => {
await client.notifications.notificationsDelete({ baseUrl: '/r' });
notifications.value = [];
};
const unreadCount = computed(() => notifications.value.filter(item => !item.read).length);
return {
notifications,
loading,
loaded,
unreadCount,
locale: computed(() => i18next.resolvedLanguage),
fetchNotifications,
markRead,
deleteNotification,
markAllRead,
clearAll,
};
}

View File

@@ -0,0 +1,59 @@
import { ref } from 'vue'
const visible = ref(false)
const progress = ref(0)
let timer: ReturnType<typeof setInterval> | null = null
function start() {
if (timer) clearInterval(timer)
visible.value = true
progress.value = 8
timer = setInterval(() => {
if (progress.value < 80) {
progress.value += Math.random() * 12
} else if (progress.value < 95) {
progress.value += Math.random() * 3
}
}, 200)
}
function finish() {
if (timer) {
clearInterval(timer)
timer = null
}
progress.value = 100
setTimeout(() => {
visible.value = false
progress.value = 0
}, 250)
}
function fail() {
if (timer) {
clearInterval(timer)
timer = null
}
progress.value = 100
setTimeout(() => {
visible.value = false
progress.value = 0
}, 250)
}
export function useRouteLoading() {
return {
visible,
progress,
start,
finish,
fail,
}
}

View File

@@ -0,0 +1,127 @@
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
import { useQuery } from '@pinia/colada';
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
export type SettingsPreferencesSnapshot = {
emailNotifications: boolean;
pushNotifications: boolean;
marketingNotifications: boolean;
telegramNotifications: boolean;
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
};
export type NotificationSettingsDraft = {
email: boolean;
push: boolean;
marketing: boolean;
telegram: boolean;
};
export type PlayerSettingsDraft = {
autoplay: boolean;
loop: boolean;
muted: boolean;
showControls: boolean;
pip: boolean;
airplay: boolean;
chromecast: boolean;
encrytion_m3u8: boolean;
};
type PreferencesResponse = {
data?: {
preferences?: PreferencesSettingsPreferencesRequest;
};
};
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
emailNotifications: true,
pushNotifications: true,
marketingNotifications: false,
telegramNotifications: false,
autoplay: false,
loop: false,
muted: false,
showControls: true,
pip: true,
airplay: true,
chromecast: true,
};
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
const preferences = (responseData as PreferencesResponse | undefined)?.data?.preferences;
return {
emailNotifications: preferences?.email_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
pushNotifications: preferences?.push_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
marketingNotifications: preferences?.marketing_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
telegramNotifications: preferences?.telegram_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
showControls: preferences?.show_controls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
};
};
export const createNotificationSettingsDraft = (
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
): NotificationSettingsDraft => ({
email: snapshot.emailNotifications,
push: snapshot.pushNotifications,
marketing: snapshot.marketingNotifications,
telegram: snapshot.telegramNotifications,
});
export const createPlayerSettingsDraft = (
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
): PlayerSettingsDraft => ({
autoplay: snapshot.autoplay,
loop: snapshot.loop,
muted: snapshot.muted,
showControls: snapshot.showControls,
pip: snapshot.pip,
airplay: snapshot.airplay,
chromecast: snapshot.chromecast,
encrytion_m3u8: snapshot.chromecast
});
export const toNotificationPreferencesPayload = (
draft: NotificationSettingsDraft,
): PreferencesSettingsPreferencesRequest => ({
email_notifications: draft.email,
push_notifications: draft.push,
marketing_notifications: draft.marketing,
telegram_notifications: draft.telegram,
});
export const toPlayerPreferencesPayload = (
draft: PlayerSettingsDraft,
): PreferencesSettingsPreferencesRequest => ({
autoplay: draft.autoplay,
loop: draft.loop,
muted: draft.muted,
show_controls: draft.showControls,
pip: draft.pip,
airplay: draft.airplay,
chromecast: draft.chromecast,
});
export function useSettingsPreferencesQuery() {
return useQuery({
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
query: async () => {
const response = await client.settings.preferencesList({ baseUrl: '/r' });
return normalizePreferencesSnapshot(response.data);
},
});
}

View File

@@ -1,3 +1,4 @@
import { client, ContentType } from '@/api/client';
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
export interface QueueItem { export interface QueueItem {
@@ -12,6 +13,9 @@ export interface QueueItem {
thumbnail?: string; thumbnail?: string;
file?: File; // Keep reference to file for local uploads file?: File; // Keep reference to file for local uploads
url?: string; // Keep reference to url for remote uploads url?: string; // Keep reference to url for remote uploads
playbackUrl?: string;
videoId?: string;
mergeId?: string;
// Upload chunk tracking // Upload chunk tracking
activeChunks?: number; activeChunks?: number;
uploadedUrls?: string[]; uploadedUrls?: string[];
@@ -40,6 +44,7 @@ const abortItem = (id: string) => {
}; };
export function useUploadQueue() { export function useUploadQueue() {
const t = (key: string, params?: Record<string, unknown>) => key;
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length)); const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
@@ -82,12 +87,12 @@ export function useUploadQueue() {
const duplicateCount = allowed.length - fresh.length; const duplicateCount = allowed.length - fresh.length;
const newItems: QueueItem[] = fresh.map((url) => ({ const newItems: QueueItem[] = fresh.map((url) => ({
id: Math.random().toString(36).substring(2, 9), id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || 'Remote File', name: url.split('/').pop() || t('upload.queueItem.remoteFileName'),
type: 'remote', type: 'remote',
status: 'pending', status: 'pending',
progress: 0, progress: 0,
uploaded: '0 MB', uploaded: '0 MB',
total: 'Unknown', total: t('upload.queueItem.unknownSize'),
speed: '0 MB/s', speed: '0 MB/s',
url: url, url: url,
activeChunks: 0, activeChunks: 0,
@@ -267,7 +272,7 @@ export function useUploadQueue() {
setTimeout(attempt, 2000); setTimeout(attempt, 2000);
} else { } else {
item.status = 'error'; item.status = 'error';
reject(new Error(`Failed to upload chunk ${index + 1}`)); reject(new Error(t('upload.errors.chunkUploadFailed', { index: index + 1 })));
} }
} }
@@ -295,9 +300,28 @@ export function useUploadQueue() {
const data = await response.json(); const data = await response.json();
if (!response.ok) { if (!response.ok) {
throw new Error(data.error || 'Merge failed'); throw new Error(data.error || t('upload.errors.mergeFailed'));
} }
const playbackUrl = data.playback_url || data.play_url;
if (!playbackUrl) {
throw new Error('Playback URL missing after merge');
}
const createResponse = await client.videos.videosCreate({
title: item.file.name.replace(/\.[^.]+$/, ''),
description: '',
url: playbackUrl,
size: item.file.size,
duration: 0,
format: item.file.type || 'video/mp4',
}, { baseUrl: '/r' });
const createdVideo = (createResponse.data as any)?.data?.video || (createResponse.data as any)?.data;
item.videoId = createdVideo?.id;
item.mergeId = data.id;
item.playbackUrl = playbackUrl;
item.url = playbackUrl;
item.status = 'complete'; item.status = 'complete';
item.progress = 100; item.progress = 100;
item.uploaded = item.total; item.uploaded = item.total;
@@ -327,7 +351,8 @@ export function useUploadQueue() {
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${value} ${sizes[i]}`;
}; };
const totalSize = computed(() => { const totalSize = computed(() => {

View File

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

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

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

View File

@@ -15,7 +15,6 @@ setupMiddlewares(app);
// API proxy middleware (handles /r/*) // API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware); app.use(apiProxyMiddleware);
// Routes // Routes
registerWellKnownRoutes(app); registerWellKnownRoutes(app);
registerMergeRoutes(app); registerMergeRoutes(app);

View File

@@ -0,0 +1,42 @@
import i18next from "i18next";
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
const backendOptions: HttpBackendOptions = {
loadPath: 'http://localhost:5173/locales/{{lng}}/{{ns}}.json',
request: (_options, url, _payload, callback) => {
fetch(url)
.then((res) =>
res.json().then((r) => {
callback(null, {
data: JSON.stringify(r),
status: 200,
})
})
)
.catch(() => {
callback(null, {
status: 500,
data: '',
})
})
},
}
export const createI18nInstance = (lng: string) => {
console.log('Initializing i18n with language:', lng);
const i18n = i18next.createInstance();
i18n
.use(I18NextHttpBackend)
.init({
lng,
supportedLngs: ["en", "vi"],
fallbackLng: "en",
defaultNS: "translation",
ns: ['translation'],
interpolation: {
escapeValue: false,
},
backend: backendOptions,
});
return i18n;
};
export default createI18nInstance;

View File

@@ -49,14 +49,14 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
}); });
} }
export const formatBytes = (bytes?: number) => { export const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B'; if (!bytes) return '0 B';
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${value} ${sizes[i]}`;
// return `${new Intl.NumberFormat(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
}; };
export const formatDuration = (seconds?: number) => { export const formatDuration = (seconds?: number) => {
@@ -73,7 +73,10 @@ export const formatDuration = (seconds?: number) => {
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => { export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
if (!dateString) return ''; if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', { const locale = typeof document !== 'undefined'
? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US'
: 'en-US';
return new Date(dateString).toLocaleDateString(locale, {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
<template>
<div class="flex flex-col items-center gap-3 py-6 text-center">
<div class="i-svg-spinners-90-ring-with-bg h-10 w-10 text-blue-600"></div>
<p class="text-sm text-gray-600">{{ message }}</p>
</div>
</template>
<script setup lang="ts">
import { useAppToast } from '@/composables/useAppToast';
import { useAuthStore } from '@/stores/auth';
import { computed, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
const auth = useAuthStore();
const route = useRoute();
const router = useRouter();
const toast = useAppToast();
const status = computed(() => String(route.query.status ?? 'error'));
const reason = computed(() => String(route.query.reason ?? 'google_login_failed'));
const reasonMessages: Record<string, string> = {
missing_state: 'Google login session is invalid. Please try again.',
invalid_state: 'Google login session has expired. Please try again.',
missing_code: 'Google did not return an authorization code.',
access_denied: 'Google login was cancelled.',
exchange_failed: 'Failed to sign in with Google.',
userinfo_failed: 'Failed to load your Google account information.',
userinfo_parse_failed: 'Failed to read your Google account information.',
missing_email: 'Your Google account did not provide an email address.',
create_user_failed: 'Failed to create your account.',
update_user_failed: 'Failed to update your account.',
reload_user_failed: 'Failed to finish signing you in.',
session_failed: 'Failed to create your sign-in session.',
fetch_me_failed: 'Signed in with Google, but failed to load your account.',
google_login_failed: 'Google login failed. Please try again.',
};
const errorMessage = computed(() => reasonMessages[reason.value] ?? reasonMessages.google_login_failed);
const message = computed(() => status.value === 'success' ? 'Signing you in with Google...' : errorMessage.value);
onMounted(async () => {
if (status.value !== 'success') {
toast.add({
severity: 'error',
summary: 'Google login failed',
detail: errorMessage.value,
life: 5000,
});
await router.replace({ name: 'login', query: { reason: reason.value } });
return;
}
try {
const user = await auth.fetchMe();
if (!user) {
throw new Error('missing_user');
}
await router.replace({ name: 'overview' });
} catch {
toast.add({
severity: 'error',
summary: 'Google login failed',
detail: 'Signed in with Google, but failed to load your account.',
life: 5000,
});
await router.replace({ name: 'login', query: { reason: 'fetch_me_failed' } });
}
});
</script>

View File

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

View File

@@ -2,17 +2,17 @@
<div class="w-full"> <div class="w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full"> <form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email</label> <label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.login.email') }}</label>
<AppInput id="email" v-model="form.email" type="text" placeholder="Enter your email" <AppInput id="email" v-model="form.email" type="text" :placeholder="t('auth.signup.placeholders.email')"
:disabled="auth.loading" /> :disabled="auth.loading" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p> <p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label> <label for="password" class="text-sm font-medium text-gray-700">{{ t('auth.login.password') }}</label>
<div class="relative"> <div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'" <AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
placeholder="Enter your password" :disabled="auth.loading" /> :placeholder="t('auth.signup.placeholders.password')" :disabled="auth.loading" />
<button type="button" <button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600" class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1"> @click="showPassword = !showPassword" tabindex="-1">
@@ -31,22 +31,15 @@
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p> <p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div> </div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-end">
<div class="flex items-center gap-2">
<input id="remember-me" v-model="form.rememberMe" type="checkbox"
class="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
:disabled="auth.loading" />
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
</div>
<div class="text-sm"> <div class="text-sm">
<router-link to="/forgot" <router-link to="/forgot"
class="text-blue-600 hover:text-blue-500 hover:underline">Forgot class="text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.forgotPassword') }}</router-link>
password?</router-link>
</div> </div>
</div> </div>
<AppButton type="submit" :loading="auth.loading" class="w-full"> <AppButton type="submit" :loading="auth.loading" class="w-full">
{{ auth.loading ? 'Signing in...' : 'Sign in' }} {{ auth.loading ? `${t('common.loading')}...` : t('auth.login.signIn') }}
</AppButton> </AppButton>
<div class="relative"> <div class="relative">
@@ -54,7 +47,7 @@
<div class="w-full border-t border-gray-300"></div> <div class="w-full border-t border-gray-300"></div>
</div> </div>
<div class="relative flex justify-center text-sm"> <div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span> <span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
</div> </div>
</div> </div>
@@ -64,13 +57,13 @@
<path <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" /> 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> </svg>
Google {{ t('auth.login.google') }}
</AppButton> </AppButton>
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600"> <div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
<p class="text-center text-sm text-gray-600"> <p class="text-center text-sm text-gray-600">
Don't have an account? {{ t('auth.login.noAccount') }}
<router-link to="/sign-up" <router-link to="/sign-up"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link> class="font-medium text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.signUp') }}</router-link>
</p> </p>
</div> </div>
</form> </form>
@@ -81,11 +74,13 @@
import { useAuthStore } from '@/stores/auth'; import { useAuthStore } from '@/stores/auth';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import { reactive, ref } from 'vue'; import { reactive, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { z } from 'zod'; import { z } from 'zod';
const toast = useAppToast(); const toast = useAppToast();
const auth = useAuthStore(); const auth = useAuthStore();
const showPassword = ref(false); const showPassword = ref(false);
const { t } = useTranslation();
const form = reactive({ const form = reactive({
email: '', email: '',
@@ -96,8 +91,8 @@ const form = reactive({
const errors = reactive<{ email?: string; password?: string }>({}); const errors = reactive<{ email?: string; password?: string }>({});
const schema = z.object({ const schema = z.object({
email: z.string().min(1, { message: 'Email or username is required.' }), email: z.string().min(1, { message: t('auth.login.errors.emailRequired') }),
password: z.string().min(1, { message: 'Password is required.' }) password: z.string().min(1, { message: t('auth.login.errors.passwordRequired') })
}); });
watch(() => auth.error, (newError) => { watch(() => auth.error, (newError) => {

View File

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

View File

@@ -1,231 +1,373 @@
<template> <script setup lang="ts">
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex"> import { computed } from 'vue';
<!-- <div class="absolute inset-0 bg-grid-pattern opacity-[0.4] -z-10"></div> --> import { useTranslation } from 'i18next-vue';
<div
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
</div>
<div
class=":m: absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply">
</div>
const { t } = useTranslation();
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center"> const signalItems = computed(() => [
<h1 { label: t('home.features.live.bitrate'), value: t('home.features.live.bitrateValue') },
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards"> { label: t('home.features.live.fps'), value: t('home.features.live.fpsValue') },
Video infrastructure for <br> { label: t('home.features.live.latency'), value: t('home.features.live.latencyValue') },
<span class="text-gradient">modern internet.</span> ]);
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50"> const featurePills = computed(() => [
Seamlessly host, encode, and stream video with our developer-first API. t('home.features.global.title'),
Optimized for speed, built for scale. t('home.features.encoding.title'),
</p> t('home.features.analytics.title'),
]);
<div class="flex flex-col sm:flex-row justify-center gap-4"> const getFeatureList = (key: string): string[] => {
<RouterLink to="/get-started" class="flex btn btn-success !rounded-xl !p-4 press-animated"> const localized = t(key, { returnObjects: true });
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580"> return Array.isArray(localized) ? localized.map((item) => String(item)) : [];
<path d="M56 284v-560L560 4 56 284z" fill="#fff" /> };
</svg>&nbsp;
Get Started
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="28" viewBox="0 0 596 468">
<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="#14a74b" />
<path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="#fff" />
</svg>&nbsp;
Upload video
</RouterLink>
</div>
</div>
</section>
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video
infrastructure.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6"> const pricing = computed(() => ({
<div title: t('home.pricing.title'),
class=":m: md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative"> subtitle: t('home.pricing.subtitle'),
<div class="relative z-10">
<div
class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532">
<path
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="#059669" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region
selection ensures the lowest latency for every viewer.</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
<path
d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z"
fill="#1e3050" />
</svg>
</div>
</div>
<div class=":m: md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
<div class=":m: absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
<div class="relative z-10">
<div
class=":m: w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384">
<path
d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z"
fill="#fff" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers
with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
<!-- Visual -->
<div
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">Live Status</span>
<span
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span
class="text-white">6000 kbps</span></div>
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span
class="text-white">60</span></div>
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span
class="text-brand-400">~2s</span></div>
</div>
</div>
</div>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570">
<path
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9" />
<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" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.
</p>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468">
<path
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and
more.</p>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ pricing.title }}</h2>
<p class="text-slate-500">{{ pricing.subtitle }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:style="{ background: pack.bg }">
<div v-if="pack.tag"
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
{{ pack.tag }}</div>
<div>
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">/mo</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon
class="fas fa-check text-brand-500" /> {{ value }}</li>
</ul>
<router-link to="/sign-up"
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{
pack.buttonText }}</router-link>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { cn } from '@/lib/utils';
const pricing = {
title: "Simple, transparent pricing",
subtitle: "Choose the plan that fits your needs. No hidden fees.",
packs: [ packs: [
{ {
name: "Hobby", name: t('home.pricing.hobby.name'),
price: "$0", price: '$0',
features: [ features: getFeatureList('home.pricing.hobby.features'),
"Unlimited upload", buttonText: t('home.pricing.hobby.button'),
"1 Hour of Storage", tag: '',
"Standard Support",
],
buttonText: "Start Free",
tag: "",
bg: "#f9fafb",
}, },
{ {
name: "Pro", name: t('home.pricing.pro.name'),
price: "$29", price: '$29',
features: [ features: getFeatureList('home.pricing.pro.features'),
"Ads free player", buttonText: t('home.pricing.pro.button'),
"Support M3U8", tag: t('home.pricing.pro.tag'),
"Unlimited upload",
"Custom ads"
],
buttonText: "Get Started",
tag: "POPULAR",
bg: "#eff6ff",
}, },
{ {
name: "Scale", name: t('home.pricing.scale.name'),
price: "$99", price: '$99',
features: [ features: getFeatureList('home.pricing.scale.features'),
"5 TB Bandwidth", buttonText: t('home.pricing.scale.button'),
"500 Hours Storage", tag: t('home.pricing.scale.tag'),
"Priority Support"
],
buttonText: "Contact Sales",
tag: "Best Value",
bg: "#eef4f7",
} }
] ]
} }));
const featuredTag = computed(() => t('home.pricing.pro.tag'));
const scaleTag = computed(() => t('home.pricing.scale.tag'));
const isFeaturedPack = (tag: string) => tag === featuredTag.value;
const isScalePack = (tag: string) => tag === scaleTag.value;
</script> </script>
<template>
<div class="bg-white text-slate-900">
<section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="pointer-events-none absolute inset-0">
<div class="absolute inset-0 opacity-60 bg-[linear-gradient(rgba(148,163,184,0.12)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.12)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.55),transparent_78%)]"></div>
<div class="absolute inset-x-0 top-0 h-[28rem] bg-[radial-gradient(circle_at_top,rgba(20,167,75,0.12),transparent_58%)]"></div>
<div class="absolute -left-16 top-28 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<div class="absolute right-0 top-20 h-72 w-72 rounded-full bg-sky-100 blur-3xl"></div>
</div>
<div class="relative mx-auto max-w-7xl px-4 pb-18 pt-28 sm:px-6 lg:px-8 lg:pb-24 lg:pt-34">
<div class="grid items-center gap-12 lg:grid-cols-[1.02fr_0.98fr] lg:gap-14">
<div>
<div class="inline-flex items-center gap-2 rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
<span class="h-2 w-2 rounded-full bg-primary"></span>
{{ t('home.features.live.onAir') }}
</div>
<h1 class="mt-7 max-w-4xl text-5xl font-bold leading-[1.02] tracking-tight text-slate-900 sm:text-6xl lg:text-7xl">
<span class="block">{{ t('home.hero.titleLine1') }}</span>
<span class="mt-2 block bg-[linear-gradient(135deg,#0f172a_0%,#14a74b_55%,#0ea5e9_100%)] bg-clip-text text-transparent">
{{ t('home.hero.titleLine2') }}
</span>
</h1>
<p class="mt-6 max-w-2xl text-lg leading-8 text-slate-600 lg:text-xl">
{{ t('home.hero.subtitle') }}
</p>
<div class="mt-9 flex flex-col gap-3 sm:flex-row">
<RouterLink to="/sign-up" class="btn btn-success !rounded-xl !px-6 !py-3.5 shadow-sm">
{{ t('home.hero.getStarted') }}
</RouterLink>
<RouterLink to="/login" class="btn btn-outline-primary !rounded-xl !px-6 !py-3.5">
{{ t('home.hero.uploadVideo') }}
</RouterLink>
</div>
<div class="mt-10 grid gap-4 sm:grid-cols-3">
<div
v-for="signal in signalItems"
:key="signal.label"
class="rounded-2xl border border-slate-200 bg-white px-5 py-4 shadow-sm"
>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ signal.label }}
</p>
<p class="mt-2 text-xl font-bold text-slate-900">
{{ signal.value }}
</p>
</div>
</div>
</div>
<div class="relative mx-auto w-full max-w-[36rem] lg:mr-0">
<div class="rounded-[2rem] border border-slate-200 bg-white p-5 shadow-[0_24px_70px_rgba(15,23,42,0.08)] sm:p-6">
<div class="flex items-center justify-between border-b border-slate-100 pb-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ t('home.features.live.status') }}
</p>
<h2 class="mt-2 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.live.title') }}
</h2>
</div>
<span class="inline-flex items-center gap-2 rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">
<span class="h-2 w-2 rounded-full bg-primary"></span>
{{ t('home.features.live.onAir') }}
</span>
</div>
<p class="mt-5 text-sm leading-7 text-slate-600 sm:text-base">
{{ t('home.features.live.description') }}
</p>
<div class="mt-5 rounded-[1.5rem] bg-slate-50 p-4 sm:p-5">
<div class="space-y-3">
<div
v-for="signal in signalItems"
:key="signal.label"
class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3"
>
<span class="text-sm text-slate-500">{{ signal.label }}</span>
<span class="text-sm font-semibold text-slate-900">{{ signal.value }}</span>
</div>
</div>
<div class="mt-5 grid grid-cols-12 items-end gap-2">
<span
v-for="n in 12"
:key="n"
class="rounded-full bg-[linear-gradient(180deg,rgba(20,167,75,0.95),rgba(20,167,75,0.2))]"
:style="{ height: `${34 + (n % 5) * 12}px`, opacity: 0.4 + (n % 4) * 0.13 }"
/>
</div>
</div>
<div class="mt-5 flex flex-wrap gap-2.5">
<span
v-for="pill in featurePills"
:key="pill"
class="rounded-full border border-slate-200 bg-slate-50 px-3 py-1.5 text-xs font-medium text-slate-600"
>
{{ pill }}
</span>
</div>
</div>
<div class="absolute -right-4 top-12 hidden w-56 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl lg:block">
<p class="text-xs font-semibold uppercase tracking-[0.18em] text-slate-400">
{{ t('home.features.global.title') }}
</p>
<p class="mt-3 text-sm leading-6 text-slate-600">
{{ t('home.features.global.description') }}
</p>
</div>
<div class="absolute -left-4 bottom-8 hidden w-64 rounded-2xl border border-slate-200 bg-white p-4 shadow-xl lg:block">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-slate-900">
{{ t('home.features.analytics.title') }}
</p>
<span class="rounded-full bg-slate-100 px-2 py-1 text-[11px] font-medium text-slate-500">
{{ t('home.features.live.onAir') }}
</span>
</div>
<p class="mt-2 text-sm leading-6 text-slate-600">
{{ t('home.features.analytics.description') }}
</p>
<div class="mt-4 grid grid-cols-4 items-end gap-2">
<span class="h-10 rounded-full bg-slate-200"></span>
<span class="h-18 rounded-full bg-primary/70"></span>
<span class="h-14 rounded-full bg-sky-300"></span>
<span class="h-22 rounded-full bg-slate-900"></span>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="features" class="border-b border-slate-100 bg-slate-50/70 py-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto mb-14 max-w-3xl text-center">
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
{{ t('home.features.heading') }}
</p>
<h2 class="mt-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
{{ t('home.features.heading') }}
</h2>
<p class="mt-4 text-lg leading-8 text-slate-600">
{{ t('home.features.subtitle') }}
</p>
</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)]">
<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
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="currentColor"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.global.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.global.description') }}
</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)]">
<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
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9"
/>
<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"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.encoding.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.encoding.description') }}
</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)]">
<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
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="currentColor"
/>
</svg>
</div>
<h3 class="mt-5 text-2xl font-bold tracking-tight text-slate-900">
{{ t('home.features.analytics.title') }}
</h3>
<p class="mt-3 text-base leading-7 text-slate-600">
{{ t('home.features.analytics.description') }}
</p>
</article>
</div>
<div class="mt-6 rounded-3xl border border-slate-200 bg-white p-6 shadow-sm 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">
{{ t('home.features.live.title') }}
</p>
<h3 class="mt-3 text-3xl font-bold tracking-tight text-slate-900">
{{ t('home.features.live.title') }}
</h3>
<p class="mt-4 max-w-2xl text-base leading-8 text-slate-600">
{{ t('home.features.live.description') }}
</p>
</div>
<div class="rounded-[1.75rem] bg-slate-50 p-4">
<div class="space-y-3">
<div
v-for="signal in signalItems"
:key="`summary-${signal.label}`"
class="flex items-center justify-between rounded-2xl border border-slate-200 bg-white px-4 py-3"
>
<span class="text-sm text-slate-500">{{ signal.label }}</span>
<span class="text-sm font-semibold text-slate-900">{{ signal.value }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<section id="pricing" class="bg-white py-20">
<div class="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div class="mx-auto mb-14 max-w-3xl text-center">
<p class="text-sm font-semibold uppercase tracking-[0.22em] text-primary">
{{ pricing.title }}
</p>
<h2 class="mt-4 text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl">
{{ pricing.title }}
</h2>
<p class="mt-4 text-lg leading-8 text-slate-600">
{{ pricing.subtitle }}
</p>
</div>
<div class="grid gap-6 lg:grid-cols-3 lg:items-stretch">
<article
v-for="pack in pricing.packs"
:key="pack.name"
:class="[
'relative flex h-full flex-col overflow-hidden rounded-3xl border 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)]',
isFeaturedPack(pack.tag)
? 'border-primary bg-primary/[0.04] ring-1 ring-primary/15'
: isScalePack(pack.tag)
? 'border-slate-200 bg-slate-50/80'
: 'border-slate-200 bg-white'
]"
>
<div
v-if="pack.tag"
:class="[
'absolute right-5 top-5 rounded-full px-3 py-1 text-xs font-semibold',
isFeaturedPack(pack.tag) ? 'bg-primary text-white' : 'bg-slate-900 text-white'
]"
>
{{ pack.tag }}
</div>
<div>
<p class="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
{{ pack.name }}
</p>
<div class="mt-5 flex items-end gap-2">
<span class="text-5xl font-bold tracking-tight text-slate-900">{{ pack.price }}</span>
<span class="pb-2 text-slate-500">{{ t('home.pricing.perMonth') }}</span>
</div>
</div>
<ul class="mt-8 space-y-4">
<li v-for="value in pack.features" :key="value" class="flex items-start gap-3">
<span class="mt-0.5 inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10 text-primary">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<path d="M5 12.5l4.2 4.2L19 7" />
</svg>
</span>
<span class="text-slate-600">{{ value }}</span>
</li>
</ul>
<RouterLink
to="/sign-up"
:class="[
'mt-10 inline-flex w-full items-center justify-center !rounded-xl !py-3.5',
isFeaturedPack(pack.tag) ? 'btn btn-success' : 'btn btn-outline-primary'
]"
>
{{ pack.buttonText }}
</RouterLink>
</article>
</div>
</div>
</section>
</div>
</template>

View File

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

View File

@@ -1,61 +1,101 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;"> <section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="grow pt-32 pb-12 px-4"> <div class="pointer-events-none absolute inset-0">
<div class="max-w-4xl mx-auto space-y-10"> <div class="absolute inset-0 opacity-55 bg-[linear-gradient(rgba(148,163,184,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.1)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.45),transparent_78%)]"></div>
<div class="space-y-3"> <div class="absolute left-0 top-20 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<p <div class="absolute right-0 top-24 h-64 w-64 rounded-full bg-sky-100 blur-3xl"></div>
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase"> </div>
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1> <div class="relative mx-auto max-w-5xl px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pb-20 lg:pt-34">
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p> <div class="mx-auto max-w-4xl">
<div class="inline-flex items-center rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
{{ pageContent.data.pageSubheading }}
</div>
<div class="mt-6 max-w-3xl space-y-4">
<h1 class="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl lg:text-6xl">
{{ pageContent.data.pageHeading }}
</h1>
<p class="text-lg leading-8 text-slate-600">
{{ pageContent.data.description }}
</p>
</div>
<div class="mt-10 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8 lg:p-10">
<div class="space-y-6">
<section
v-for="(item, index) in pageContent.data.list"
:key="index"
class="rounded-2xl border border-slate-200 bg-slate-50/70 p-5 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-primary/25 hover:bg-white hover:shadow-[0_14px_32px_rgba(15,23,42,0.06)] sm:p-6"
>
<div class="flex items-start gap-4">
<div class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-sm font-bold text-primary">
{{ index + 1 }}
</div>
<div class="min-w-0">
<h2 class="text-xl font-bold tracking-tight text-slate-900 sm:text-2xl">
{{ item.heading }}
</h2>
<p class="mt-3 leading-8 text-slate-600">
{{ item.text }}
</p>
</div>
</div> </div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section> </section>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useHead} from "@unhead/vue"; import { computed } from 'vue';
const title = "Privacy Policy - Ecostream"; import { useTranslation } from 'i18next-vue';
const description = "Read about Ecostream's commitment to protecting your privacy and data security."; import { useHead } from '@unhead/vue';
const pageContent = {
const { t } = useTranslation();
const pageContent = computed(() => {
const title = t('legal.privacy.title');
const description = t('legal.privacy.description');
return {
head: { head: {
title, title,
meta: [ meta: [
{ name: "description", content: description }, { name: 'description', content: description },
{ property: "og:title", content: title }, { property: 'og:title', content: title },
{ property: "og:description", content: description }, { property: 'og:description', content: description },
{ property: "twitter:title", content: title }, { property: 'twitter:title', content: title },
{ property: "twitter:description", content: description }, { property: 'twitter:description', content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" } { property: 'twitter:image', content: 'https://Ecostream.com/thumb.png' }
] ]
}, },
data: { data: {
pageHeading: "Legal & Privacy Policy", pageHeading: t('legal.privacy.pageHeading'),
pageSubheading: "Legal & Privacy Policy", pageSubheading: t('legal.privacy.pageSubheading'),
description: "Our legal and privacy policy.", description: t('legal.privacy.pageDescription'),
list: [{ list: [
heading: "1. Privacy Policy", {
text: "At Ecostream, we take your privacy seriously. This policy describes how we collect, use, and protect your personal information. We only collect information that is necessary for the operation of our service, including email addresses for account creation and payment information for subscription processing." heading: t('legal.privacy.sections.policyTitle'),
text: t('legal.privacy.sections.policyText')
}, },
{ {
heading: "2. Data Collection", heading: t('legal.privacy.sections.dataCollectionTitle'),
text: "We collect data such as IP addresses, browser types, and access times to analyze trends and improve our service. Uploaded content is stored securely and is only accessed as required for the delivery of our hosting services." text: t('legal.privacy.sections.dataCollectionText')
}, },
{ {
heading: "3. Cookie Policy", heading: t('legal.privacy.sections.cookieTitle'),
text: "We use cookies to maintain user sessions and preferences. By using our website, you consent to the use of cookies in accordance with this policy." text: t('legal.privacy.sections.cookieText')
}, },
{ {
heading: "4. DMCA & Copyright", heading: t('legal.privacy.sections.dmcaTitle'),
text: "Ecostream respects the intellectual property rights of others. We respond to notices of alleged copyright infringement in accordance with the Digital Millennium Copyright Act (DMCA). Please report any copyright violations to our support team." text: t('legal.privacy.sections.dmcaText')
}]
} }
]
} }
useHead(pageContent.head); };
});
useHead(() => pageContent.value.head);
</script> </script>

View File

@@ -1,67 +1,105 @@
<template> <template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;"> <section class="relative overflow-hidden border-b border-slate-100 bg-gradient-to-b from-slate-50 via-white to-white">
<div class="grow pt-32 pb-12 px-4"> <div class="pointer-events-none absolute inset-0">
<div class="max-w-4xl mx-auto space-y-10"> <div class="absolute inset-0 opacity-55 bg-[linear-gradient(rgba(148,163,184,0.1)_1px,transparent_1px),linear-gradient(90deg,rgba(148,163,184,0.1)_1px,transparent_1px)] bg-[length:64px_64px] [mask-image:linear-gradient(to_bottom,rgba(0,0,0,0.45),transparent_78%)]"></div>
<div class="space-y-3"> <div class="absolute left-0 top-20 h-56 w-56 rounded-full bg-primary/10 blur-3xl"></div>
<p <div class="absolute right-0 top-24 h-64 w-64 rounded-full bg-sky-100 blur-3xl"></div>
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase"> </div>
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1> <div class="relative mx-auto max-w-5xl px-4 pb-16 pt-28 sm:px-6 lg:px-8 lg:pb-20 lg:pt-34">
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p> <div class="mx-auto max-w-4xl">
<div class="inline-flex items-center rounded-full border border-primary/15 bg-white/90 px-4 py-2 text-xs font-semibold uppercase tracking-[0.22em] text-primary shadow-sm">
{{ pageContent.data.pageSubheading }}
</div>
<div class="mt-6 max-w-3xl space-y-4">
<h1 class="text-4xl font-bold tracking-tight text-slate-900 sm:text-5xl lg:text-6xl">
{{ pageContent.data.pageHeading }}
</h1>
<p class="text-lg leading-8 text-slate-600">
{{ pageContent.data.description }}
</p>
</div>
<div class="mt-10 rounded-[2rem] border border-slate-200 bg-white p-6 shadow-sm sm:p-8 lg:p-10">
<div class="space-y-6">
<section
v-for="(item, index) in pageContent.data.list"
:key="index"
class="rounded-2xl border border-slate-200 bg-slate-50/70 p-5 transition-all duration-200 ease-out hover:-translate-y-0.5 hover:border-primary/25 hover:bg-white hover:shadow-[0_14px_32px_rgba(15,23,42,0.06)] sm:p-6"
>
<div class="flex items-start gap-4">
<div class="inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-sm font-bold text-primary">
{{ index + 1 }}
</div>
<div class="min-w-0">
<h2 class="text-xl font-bold tracking-tight text-slate-900 sm:text-2xl">
{{ item.heading }}
</h2>
<p class="mt-3 leading-8 text-slate-600">
{{ item.text }}
</p>
</div>
</div> </div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section> </section>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</section>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {useHead} from "@unhead/vue"; import { computed } from 'vue';
const title = "Terms and Conditions - Ecostream"; import { useTranslation } from 'i18next-vue';
const description = "Read Ecostream's terms and conditions for using our video hosting and streaming services."; import { useHead } from '@unhead/vue';
const pageContent = {
const { t } = useTranslation();
const pageContent = computed(() => {
const title = t('legal.terms.title');
const description = t('legal.terms.description');
return {
head: { head: {
title, title,
meta: [ meta: [
{ name: "description", content: description }, { name: 'description', content: description },
{ property: "og:title", content: title }, { property: 'og:title', content: title },
{ property: "og:description", content: description }, { property: 'og:description', content: description },
{ property: "twitter:title", content: title }, { property: 'twitter:title', content: title },
{ property: "twitter:description", content: description }, { property: 'twitter:description', content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" } { property: 'twitter:image', content: 'https://Ecostream.com/thumb.png' }
] ]
}, },
data: { data: {
pageHeading: "Terms and Conditions Details", pageHeading: t('legal.terms.pageHeading'),
pageSubheading: "Terms and Conditions", pageSubheading: t('legal.terms.pageSubheading'),
description: "Our terms and conditions set forth important guidelines and rules for using Ecostream's services.", description: t('legal.terms.pageDescription'),
list: [ list: [
{ {
heading: "1. Acceptance of Terms", heading: t('legal.terms.sections.acceptanceTitle'),
text: "By accessing and using Ecostream, you accept and agree to be bound by the terms and provision of this agreement." text: t('legal.terms.sections.acceptanceText')
}, },
{ {
heading: "2. Service Usage", heading: t('legal.terms.sections.usageTitle'),
text: "You agree to use our service only for lawful purposes. You are prohibited from posting or transmitting any unlawful, threatening, libelous, defamatory, obscene, or profane material. We reserve the right to terminate accounts that violate these terms." text: t('legal.terms.sections.usageText')
}, },
{ {
heading: "3. Content Ownership", heading: t('legal.terms.sections.ownershipTitle'),
text: "You retain all rights and ownership of the content you upload to Ecostream. However, by uploading content, you grant us a license to host, store, and display the content as necessary to provide our services." text: t('legal.terms.sections.ownershipText')
}, },
{ {
heading: "4. Limitation of Liability", heading: t('legal.terms.sections.liabilityTitle'),
text: "Ecostream shall not be liable for any direct, indirect, incidental, special, or consequential damages resulting from the use or inability to use our service." text: t('legal.terms.sections.liabilityText')
}, },
{ {
heading: "5. Changes to Terms", heading: t('legal.terms.sections.changesTitle'),
text: "We reserve the right to modify these terms at any time. Your continued use of the service after any such changes constitutes your acceptance of the new terms." text: t('legal.terms.sections.changesText')
} }
] ]
} }
} };
useHead(pageContent.head); });
useHead(() => pageContent.value.head);
</script> </script>

View File

@@ -1,3 +1,4 @@
import { useRouteLoading } from "@/composables/useRouteLoading";
import { useAuthStore } from "@/stores/auth"; import { useAuthStore } from "@/stores/auth";
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue"; import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
import { inject } from "vue"; import { inject } from "vue";
@@ -68,6 +69,11 @@ const routes: RouteData[] = [
name: "forgot", name: "forgot",
component: () => import("./auth/forgot.vue"), component: () => import("./auth/forgot.vue"),
}, },
{
path: "auth/google/finalize",
name: "google-auth-finalize",
component: () => import("./auth/google-finalize.vue"),
},
], ],
}, },
{ {
@@ -85,16 +91,6 @@ const routes: RouteData[] = [
}, },
}, },
}, },
// {
// path: "upload",
// name: "upload",
// component: () => import("./upload/Upload.vue"),
// meta: {
// head: {
// title: "Upload - Holistream",
// },
// },
// },
{ {
path: "videos", path: "videos",
children: [ children: [
@@ -114,16 +110,6 @@ const routes: RouteData[] = [
}, },
}, },
}, },
// {
// path: ":id",
// name: "video-detail",
// component: () => import("./video/DetailVideo.vue"),
// meta: {
// head: {
// title: "Edit Video - Holistream",
// },
// },
// },
], ],
}, },
{ {
@@ -156,7 +142,7 @@ const routes: RouteData[] = [
{ {
path: "security", path: "security",
name: "settings-security", name: "settings-security",
component: () => import("./settings/pages/SecurityNConnected.vue"), component: () => import("./settings/SecurityNConnected/SecurityNConnected.vue"),
meta: { meta: {
head: { head: {
title: "Security & Connected Apps - Holistream", title: "Security & Connected Apps - Holistream",
@@ -166,7 +152,7 @@ const routes: RouteData[] = [
{ {
path: "billing", path: "billing",
name: "settings-billing", name: "settings-billing",
component: () => import("./settings/pages/Billing.vue"), component: () => import("./settings/Billing/Billing.vue"),
meta: { meta: {
head: { head: {
title: "Billing & Plans - Holistream", title: "Billing & Plans - Holistream",
@@ -182,7 +168,7 @@ const routes: RouteData[] = [
{ {
path: "notifications", path: "notifications",
name: "settings-notifications", name: "settings-notifications",
component: () => import("./settings/pages/NotificationSettings.vue"), component: () => import("./settings/NotificationSettings/NotificationSettings.vue"),
meta: { meta: {
head: { head: {
title: "Notifications - Holistream", title: "Notifications - Holistream",
@@ -192,7 +178,7 @@ const routes: RouteData[] = [
{ {
path: "player", path: "player",
name: "settings-player", name: "settings-player",
component: () => import("./settings/pages/PlayerSettings.vue"), component: () => import("./settings/PlayerSettings/PlayerSettings.vue"),
meta: { meta: {
head: { head: {
title: "Player Settings - Holistream", title: "Player Settings - Holistream",
@@ -202,7 +188,7 @@ const routes: RouteData[] = [
{ {
path: "domains", path: "domains",
name: "settings-domains", name: "settings-domains",
component: () => import("./settings/pages/DomainsDns.vue"), component: () => import("./settings/DomainsDns/DomainsDns.vue"),
meta: { meta: {
head: { head: {
title: "Allowed Domains - Holistream", title: "Allowed Domains - Holistream",
@@ -212,7 +198,7 @@ const routes: RouteData[] = [
{ {
path: "ads", path: "ads",
name: "settings-ads", name: "settings-ads",
component: () => import("./settings/pages/AdsVast.vue"), component: () => import("./settings/AdsVast/AdsVast.vue"),
meta: { meta: {
head: { head: {
title: "Ads & VAST - Holistream", title: "Ads & VAST - Holistream",
@@ -222,7 +208,7 @@ const routes: RouteData[] = [
{ {
path: "danger", path: "danger",
name: "settings-danger", name: "settings-danger",
component: () => import("./settings/pages/DangerZone.vue"), component: () => import("./settings/DangerZone/DangerZone.vue"),
meta: { meta: {
head: { head: {
title: "Danger Zone - Holistream", title: "Danger Zone - Holistream",
@@ -255,16 +241,27 @@ const createAppRouter = () => {
}, },
}); });
const loading = useRouteLoading()
router.beforeEach((to, from) => { router.beforeEach((to, from) => {
const auth = useAuthStore(); const auth = useAuthStore();
const head = inject(headSymbol); const head = inject(headSymbol);
(head as any).push(to.meta.head || {}); (head as any).push(to.meta.head || {});
if (to.fullPath !== from.fullPath && !import.meta.env.SSR) {
loading.start()
}
if (to.matched.some((record) => record.meta.requiresAuth)) { if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) { if (!auth.user) {
return { name: "login" }; return { name: "login" };
} }
} }
}); });
router.afterEach(() => {
loading.finish()
})
router.onError(() => {
loading.fail()
})
return router; return router;
}; };

View File

@@ -1,134 +1,67 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import { computed, ref } from 'vue';
import NotificationActions from './components/NotificationActions.vue'; import NotificationActions from './components/NotificationActions.vue';
import NotificationList from './components/NotificationList.vue'; import NotificationList from './components/NotificationList.vue';
import NotificationTabs from './components/NotificationTabs.vue'; import NotificationTabs from './components/NotificationTabs.vue';
import { useNotifications } from '@/composables/useNotifications';
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
interface Notification {
id: string;
type: NotificationType;
title: string;
message: string;
time: string;
read: boolean;
actionUrl?: string;
actionLabel?: string;
}
const loading = ref(false);
const activeTab = ref('all'); const activeTab = ref('all');
const { t } = useTranslation();
const notificationStore = useNotifications();
// Mock notifications data onMounted(() => {
const notifications = ref<Notification[]>([ void notificationStore.fetchNotifications();
{ });
id: '1',
type: 'video', const unreadCount = computed(() => notificationStore.unreadCount.value);
title: 'Video processing complete',
message: 'Your video "Summer Vacation 2024" has been successfully processed and is now ready to stream.',
time: '2 minutes ago',
read: false,
actionUrl: '/video',
actionLabel: 'View video'
},
{
id: '2',
type: 'payment',
title: 'Payment successful',
message: 'Your subscription to Pro Plan has been renewed successfully. Next billing date: Feb 25, 2026.',
time: '1 hour ago',
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'View receipt'
},
{
id: '3',
type: 'warning',
title: 'Storage almost full',
message: 'You have used 85% of your storage quota. Consider upgrading your plan for more space.',
time: '3 hours ago',
read: false,
actionUrl: '/payments-and-plans',
actionLabel: 'Upgrade plan'
},
{
id: '4',
type: 'success',
title: 'Upload successful',
message: 'Your video "Product Demo v2" has been uploaded successfully.',
time: '1 day ago',
read: true
},
{
id: '5',
type: 'system',
title: 'Scheduled maintenance',
message: 'We will perform scheduled maintenance on Jan 30, 2026 from 2:00 AM to 4:00 AM UTC.',
time: '2 days ago',
read: true
},
{
id: '6',
type: 'info',
title: 'New feature available',
message: 'We just launched video analytics! Track your video performance with detailed insights.',
time: '3 days ago',
read: true,
actionUrl: '/video',
actionLabel: 'Try it now'
}
]);
const tabs = computed(() => [ const tabs = computed(() => [
{ key: 'all', label: 'All', icon: 'i-lucide-inbox', count: notifications.value.length }, { key: 'all', label: t('notification.tabs.all'), icon: 'i-lucide-inbox', count: notificationStore.notifications.value.length },
{ key: 'unread', label: 'Unread', icon: 'i-lucide-bell-dot', count: unreadCount.value }, { key: 'unread', label: t('notification.tabs.unread'), icon: 'i-lucide-bell-dot', count: unreadCount.value },
{ key: 'video', label: 'Videos', icon: 'i-lucide-video', count: notifications.value.filter(n => n.type === 'video').length }, { key: 'video', label: t('notification.tabs.videos'), icon: 'i-lucide-video', count: notificationStore.notifications.value.filter(n => n.type === 'video').length },
{ key: 'payment', label: 'Payments', icon: 'i-lucide-credit-card', count: notifications.value.filter(n => n.type === 'payment').length } { key: 'payment', label: t('notification.tabs.payments'), icon: 'i-lucide-credit-card', count: notificationStore.notifications.value.filter(n => n.type === 'payment').length },
]); ]);
const filteredNotifications = computed(() => { const filteredNotifications = computed(() => {
if (activeTab.value === 'all') return notifications.value; if (activeTab.value === 'all') return notificationStore.notifications.value;
if (activeTab.value === 'unread') return notifications.value.filter(n => !n.read); if (activeTab.value === 'unread') return notificationStore.notifications.value.filter(n => !n.read);
return notifications.value.filter(n => n.type === activeTab.value); return notificationStore.notifications.value.filter(n => n.type === activeTab.value);
}); });
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length); const handleMarkRead = async (id: string) => {
await notificationStore.markRead(id);
const handleMarkRead = (id: string) => {
const notification = notifications.value.find(n => n.id === id);
if (notification) notification.read = true;
}; };
const handleDelete = (id: string) => { const handleDelete = async (id: string) => {
notifications.value = notifications.value.filter(n => n.id !== id); await notificationStore.deleteNotification(id);
}; };
const handleMarkAllRead = () => { const handleMarkAllRead = async () => {
notifications.value.forEach(n => n.read = true); await notificationStore.markAllRead();
}; };
const handleClearAll = () => { const handleClearAll = async () => {
notifications.value = []; await notificationStore.clearAll();
}; };
</script> </script>
<template> <template>
<div> <div>
<PageHeader <PageHeader
title="Notifications" :title="t('notification.title')"
description="Stay updated with your latest activities and alerts." :description="t('notification.subtitle')"
:breadcrumbs="[ :breadcrumbs="[
{ label: 'Dashboard', to: '/' }, { label: t('pageHeader.dashboard'), to: '/' },
{ label: 'Notifications' } { label: t('nav.notification') }
]" ]"
/> />
<div class="w-full max-w-4xl mx-auto mt-6"> <div class="w-full max-w-4xl mx-auto mt-6">
<div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm"> <div class="notification-container bg-white rounded-2xl border border-gray-200 p-6 shadow-sm">
<NotificationActions <NotificationActions
:loading="loading" :loading="notificationStore.loading.value"
:total-count="notifications.length" :total-count="notificationStore.notifications.value.length"
:unread-count="unreadCount" :unread-count="unreadCount"
@mark-all-read="handleMarkAllRead" @mark-all-read="handleMarkAllRead"
@clear-all="handleClearAll" @clear-all="handleClearAll"
@@ -142,7 +75,7 @@ const handleClearAll = () => {
<NotificationList <NotificationList
:notifications="filteredNotifications" :notifications="filteredNotifications"
:loading="loading" :loading="notificationStore.loading.value"
@mark-read="handleMarkRead" @mark-read="handleMarkRead"
@delete="handleDelete" @delete="handleDelete"
/> />

View File

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

View File

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

View File

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

View File

@@ -1,76 +1,62 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { client, type ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import { useUsageQuery } from '@/composables/useUsageQuery';
import PageHeader from '@/components/dashboard/PageHeader.vue'; import PageHeader from '@/components/dashboard/PageHeader.vue';
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import NameGradient from './components/NameGradient.vue'; import NameGradient from './components/NameGradient.vue';
import QuickActions from './components/QuickActions.vue'; import QuickActions from './components/QuickActions.vue';
import RecentVideos from './components/RecentVideos.vue'; import RecentVideos from './components/RecentVideos.vue';
import StatsOverview from './components/StatsOverview.vue'; import StatsOverview from './components/StatsOverview.vue';
const loading = ref(true); const recentVideosLoading = ref(true);
const recentVideos = ref<ModelVideo[]>([]); const recentVideos = ref<ModelVideo[]>([]);
const { data: usageSnapshot, isPending: isUsagePending } = useUsageQuery();
// Mock stats data (in real app, fetch from API) const stats = computed(() => ({
const stats = ref({ totalVideos: usageSnapshot.value?.totalVideos ?? 0,
totalVideos: 0, totalViews: recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0),
totalViews: 0, storageUsed: usageSnapshot.value?.totalStorage ?? 0,
storageUsed: 0, storageLimit: 10737418240,
storageLimit: 10737418240, // 10GB in bytes }));
uploadsThisMonth: 0 const statsLoading = computed(() => recentVideosLoading.value || (isUsagePending.value && !usageSnapshot.value));
});
const fetchDashboardData = async () => { const fetchDashboardData = async () => {
loading.value = true; recentVideosLoading.value = true;
try { try {
// Fetch recent videos const response = await client.videos.videosList({ page: 1, limit: 5 }, { baseUrl: '/r' });
const response = await client.videos.videosList({ page: 1, limit: 5 });
const body = response.data as any; const body = response.data as any;
if (body.data && Array.isArray(body.data)) { const videos = Array.isArray(body?.data?.videos)
recentVideos.value = body.data; ? body.data.videos
stats.value.totalVideos = body.data.length; : Array.isArray(body?.videos)
} else if (Array.isArray(body)) { ? body.videos
recentVideos.value = body; : [];
stats.value.totalVideos = body.length;
}
// Calculate mock stats recentVideos.value = videos;
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
const uploadDate = new Date(v.created_at || '');
const now = new Date();
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
}).length;
} catch (err) { } catch (err) {
console.error('Failed to fetch dashboard data:', err); console.error('Failed to fetch dashboard data:', err);
} finally { } finally {
loading.value = false; recentVideosLoading.value = false;
} }
}; };
onMounted(() => { onMounted(() => {
fetchDashboardData(); fetchDashboardData();
}); });
</script> </script>
<template> <template>
<div class="dashboard-overview"> <div class="dashboard-overview">
<PageHeader :title="NameGradient" description="Welcome back, Here's what's happening with your videos." :breadcrumbs="[ <PageHeader :title="NameGradient" :description="$t('overview.welcome.subtitle')" :breadcrumbs="[
{ label: 'Dashboard' } { label: $t('pageHeader.dashboard') }
]" /> ]" />
<!-- Stats Grid --> <StatsOverview :loading="statsLoading" :stats="stats" />
<StatsOverview :loading="loading" :stats="stats" />
<!-- Quick Actions --> <QuickActions :loading="recentVideosLoading" />
<QuickActions :loading="loading" />
<!-- Recent Videos --> <RecentVideos :loading="recentVideosLoading" :videos="recentVideos" />
<RecentVideos :loading="loading" :videos="recentVideos" />
<!-- Storage Usage -->
<!-- <StorageUsage :loading="loading" :stats="stats" /> --> <!-- <StorageUsage :loading="loading" :stats="stats" /> -->
</div> </div>
</template> </template>

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useTranslation } from 'i18next-vue';
import StatsCard from '@/components/dashboard/StatsCard.vue'; import StatsCard from '@/components/dashboard/StatsCard.vue';
import { formatBytes } from '@/lib/utils'; import { formatBytes } from '@/lib/utils';
@@ -9,16 +11,17 @@ interface Props {
totalViews: number; totalViews: number;
storageUsed: number; storageUsed: number;
storageLimit: number; storageLimit: number;
uploadsThisMonth: number;
}; };
} }
defineProps<Props>(); const props = defineProps<Props>();
const { t, i18next } = useTranslation();
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
</script> </script>
<template> <template>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6"> <div v-for="i in 3" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div class="space-y-2"> <div class="space-y-2">
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" /> <div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
@@ -29,16 +32,13 @@ defineProps<Props>();
</div> </div>
</div> </div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8"> <div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
<StatsCard title="Total Videos" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" /> <StatsCard :title="t('overview.stats.totalVideos')" :value="stats.totalVideos" :trend="{ value: 12, isPositive: true }" />
<StatsCard title="Total Views" :value="stats.totalViews.toLocaleString()" <StatsCard :title="t('overview.stats.totalViews')" :value="stats.totalViews.toLocaleString(localeTag)"
:trend="{ value: 8, isPositive: true }" /> :trend="{ value: 8, isPositive: true }" />
<StatsCard title="Storage Used" <StatsCard :title="t('overview.stats.storageUsed')"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" /> :value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`" color="warning" />
<StatsCard title="Uploads This Month" :value="stats.uploadsThisMonth" color="success"
:trend="{ value: 25, isPositive: true }" />
</div> </div>
</template> </template>

View File

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

View File

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

View File

@@ -0,0 +1,613 @@
<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 { 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';
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 showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
const saving = ref(false);
const deletingId = ref<string | null>(null);
const togglingId = ref<string | null>(null);
const defaultingId = ref<string | null>(null);
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
isDefault: false,
});
const isFreePlan = computed(() => !auth.user?.plan_id);
const isMutating = computed(() => saving.value || deletingId.value !== null || togglingId.value !== null || defaultingId.value !== null);
const canMarkAsDefaultInDialog = computed(() => !isFreePlan.value && (!editingTemplate.value || editingTemplate.value.enabled));
const mapTemplate = (item: AdTemplateApiItem): VastTemplate => ({
id: item.id || `${item.name || 'template'}:${item.vast_tag_url || item.created_at || ''}`,
name: item.name || '',
vastUrl: item.vast_tag_url || '',
adFormat: item.ad_format || 'pre-roll',
duration: typeof item.duration === 'number' ? item.duration : undefined,
enabled: Boolean(item.is_active),
isDefault: Boolean(item.is_default),
createdAt: item.created_at || '',
});
const { data: templatesSnapshot, error, isPending, refetch } = useQuery({
key: () => ['settings', 'ad-templates'],
query: async () => {
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
return ((((response.data as any)?.data?.templates) || []) as AdTemplateApiItem[]).map(mapTemplate);
},
});
const templates = computed(() => templatesSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !templatesSnapshot.value);
const refetchTemplates = () => refetch((fetchError) => {
throw fetchError;
});
const getErrorMessage = (value: any, fallback: string) => value?.error?.message || value?.message || value?.data?.message || fallback;
const showActionErrorToast = (value: any) => {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.failedSummary'),
detail: getErrorMessage(value, t('settings.adsVast.toast.failedDetail')),
life: 5000,
});
};
const showUpgradeRequiredToast = () => {
toast.add({
severity: 'warn',
summary: t('settings.adsVast.toast.upgradeRequiredSummary'),
detail: t('settings.adsVast.toast.upgradeRequiredDetail'),
life: 4000,
});
};
const ensurePaidPlan = () => {
if (!isFreePlan.value) return true;
showUpgradeRequiredToast();
return false;
};
watch(error, (value, previous) => {
if (!value || value === previous || isMutating.value) return;
showActionErrorToast(value);
});
const resetForm = () => {
formData.value = {
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
isDefault: false,
};
editingTemplate.value = null;
};
const closeDialog = () => {
showAddDialog.value = false;
resetForm();
};
const openAddDialog = () => {
if (!ensurePaidPlan()) return;
resetForm();
showAddDialog.value = true;
};
const openEditDialog = (template: VastTemplate) => {
if (!ensurePaidPlan()) return;
formData.value = {
name: template.name,
vastUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.duration,
isDefault: template.isDefault,
};
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,
duration: formData.value.adFormat === 'mid-roll' ? formData.value.duration : undefined,
is_active: enabled,
is_default: enabled ? formData.value.isDefault : false,
});
const handleSave = async () => {
if (saving.value || !ensurePaidPlan()) return;
if (!formData.value.name.trim()) {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.nameRequiredSummary'),
detail: t('settings.adsVast.toast.nameRequiredDetail'),
life: 3000,
});
return;
}
if (!formData.value.vastUrl.trim()) {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.urlRequiredSummary'),
detail: t('settings.adsVast.toast.urlRequiredDetail'),
life: 3000,
});
return;
}
try {
new URL(formData.value.vastUrl);
} catch {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.invalidUrlSummary'),
detail: t('settings.adsVast.toast.invalidUrlDetail'),
life: 3000,
});
return;
}
if (formData.value.adFormat === 'mid-roll' && (!formData.value.duration || formData.value.duration <= 0)) {
toast.add({
severity: 'error',
summary: t('settings.adsVast.toast.durationRequiredSummary'),
detail: t('settings.adsVast.toast.durationRequiredDetail'),
life: 3000,
});
return;
}
saving.value = true;
try {
if (editingTemplate.value) {
await client.adTemplates.adTemplatesUpdate(
editingTemplate.value.id,
buildRequestBody(editingTemplate.value.enabled),
{ baseUrl: '/r' },
);
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.updatedSummary'),
detail: t('settings.adsVast.toast.updatedDetail'),
life: 3000,
});
} else {
await client.adTemplates.adTemplatesCreate(buildRequestBody(true), { baseUrl: '/r' });
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.createdSummary'),
detail: t('settings.adsVast.toast.createdDetail'),
life: 3000,
});
}
await refetchTemplates();
closeDialog();
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
saving.value = false;
}
};
const handleToggle = async (template: VastTemplate, nextValue: boolean) => {
if (!ensurePaidPlan()) return;
togglingId.value = template.id;
try {
await client.adTemplates.adTemplatesUpdate(template.id, {
name: template.name,
vast_tag_url: template.vastUrl,
ad_format: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
is_active: nextValue,
is_default: nextValue ? template.isDefault : false,
}, { baseUrl: '/r' });
await refetchTemplates();
toast.add({
severity: 'info',
summary: nextValue
? t('settings.adsVast.toast.enabledSummary')
: t('settings.adsVast.toast.disabledSummary'),
detail: t('settings.adsVast.toast.toggleDetail', {
name: template.name,
state: nextValue
? t('settings.adsVast.state.enabled')
: t('settings.adsVast.state.disabled'),
}),
life: 2000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
togglingId.value = null;
}
};
const handleSetDefault = async (template: VastTemplate) => {
if (template.isDefault || !template.enabled || !ensurePaidPlan()) return;
defaultingId.value = template.id;
try {
await client.adTemplates.adTemplatesUpdate(template.id, {
name: template.name,
vast_tag_url: template.vastUrl,
ad_format: template.adFormat,
duration: template.adFormat === 'mid-roll' ? template.duration : undefined,
is_active: template.enabled,
is_default: true,
}, { baseUrl: '/r' });
await refetchTemplates();
toast.add({
severity: 'success',
summary: t('settings.adsVast.toast.defaultUpdatedSummary'),
detail: t('settings.adsVast.toast.defaultUpdatedDetail', { name: template.name }),
life: 3000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
defaultingId.value = null;
}
};
const handleDelete = (template: VastTemplate) => {
if (!ensurePaidPlan()) return;
confirm.require({
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;
try {
await client.adTemplates.adTemplatesDelete(template.id, { baseUrl: '/r' });
await refetchTemplates();
toast.add({
severity: 'info',
summary: t('settings.adsVast.toast.deletedSummary'),
detail: t('settings.adsVast.toast.deletedDetail'),
life: 3000,
});
} catch (value: any) {
console.error(value);
showActionErrorToast(value);
} finally {
deletingId.value = null;
}
},
});
};
const copyToClipboard = 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>
<SettingsSectionCard
:title="t('settings.content.ads.title')"
:description="t('settings.content.ads.subtitle')"
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>
</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>
<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
:visible="showAddDialog"
:title="editingTemplate ? t('settings.adsVast.dialog.editTitle') : t('settings.adsVast.dialog.createTitle')"
maxWidthClass="max-w-lg"
@update:visible="showAddDialog = $event"
@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,814 @@
<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 { useAppToast } from '@/composables/useAppToast';
import { useUsageQuery } from '@/composables/useUsageQuery';
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 { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import { useTranslation } from 'i18next-vue';
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 PaymentHistoryItem = {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
currency: string;
kind: string;
details?: string[];
};
type ApiErrorPayload = {
code?: number;
message?: string;
data?: Record<string, any>;
};
const toast = useAppToast();
const auth = useAuthStore();
const { t, i18next } = useTranslation();
const { data: plansResponse, isLoading } = useQuery({
key: () => ['billing-plans'],
query: () => client.plans.plansList({ baseUrl: '/r' }),
});
const { data: usageSnapshot, refetch: refetchUsage } = useUsageQuery();
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(null);
const topupLoading = ref(false);
const historyLoading = ref(false);
const downloadingInvoiceId = ref<string | null>(null);
const topupPresets = [10, 20, 50, 100];
const paymentHistory = ref<PaymentHistoryItem[]>([]);
const upgradeDialogVisible = ref(false);
const selectedPlan = ref<ModelPlan | null>(null);
const selectedTermMonths = ref<number>(1);
const selectedPaymentMethod = ref<UpgradePaymentMethod>('wallet');
const purchaseTopupAmount = ref<number | null>(null);
const purchaseLoading = ref(false);
const purchaseError = ref<string | null>(null);
const plans = computed(() => {
const body = plansResponse.value?.data as PlansEnvelope | undefined;
const payload = body?.data;
if (Array.isArray(payload)) return payload;
if (payload && typeof payload === 'object' && Array.isArray(payload.plans)) {
return payload.plans;
}
return [] as ModelPlan[];
});
const currentPlanId = computed(() => auth.user?.plan_id || undefined);
const currentPlan = computed(() => plans.value.find(plan => plan.id === currentPlanId.value));
const currentPlanName = computed(() => currentPlan.value?.name || t('settings.billing.unknownPlan'));
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const storageUsed = computed(() => usageSnapshot.value?.totalStorage ?? 0);
const uploadsUsed = computed(() => usageSnapshot.value?.totalVideos ?? 0);
const storageLimit = computed(() => {
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
return activePlan?.storage_limit || 10737418240;
});
const uploadsLimit = computed(() => {
const activePlan = plans.value.find(plan => plan.id === currentPlanId.value);
return activePlan?.upload_limit || 50;
});
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100),
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100),
);
const localeTag = computed(() => i18next.resolvedLanguage === 'vi' ? 'vi-VN' : 'en-US');
const currencyFormatter = computed(() => new Intl.NumberFormat(localeTag.value, {
style: 'currency',
currency: 'USD',
maximumFractionDigits: 2,
}));
const shortDateFormatter = computed(() => new Intl.DateTimeFormat(localeTag.value, {
month: 'short',
day: 'numeric',
year: 'numeric',
}));
const selectedPlanId = computed(() => upgradeDialogVisible.value ? selectedPlan.value?.id || null : null);
const selectedPlanPrice = computed(() => selectedPlan.value?.price || 0);
const selectedTotalAmount = computed(() => selectedPlanPrice.value * selectedTermMonths.value);
const selectedShortfall = computed(() => Math.max(selectedTotalAmount.value - walletBalance.value, 0));
const selectedNeedsTopup = computed(() => selectedShortfall.value > 0.000001);
const canSubmitUpgrade = computed(() => {
if (!selectedPlan.value?.id || purchaseLoading.value) return false;
if (!selectedNeedsTopup.value) return true;
if (selectedPaymentMethod.value !== 'topup') return false;
return (purchaseTopupAmount.value || 0) >= selectedShortfall.value && (purchaseTopupAmount.value || 0) > 0;
});
const upgradeSubmitLabel = computed(() => {
if (selectedNeedsTopup.value && selectedPaymentMethod.value === 'topup') {
return t('settings.billing.upgradeDialog.topupAndUpgrade');
}
return t('settings.billing.upgradeDialog.payWithWallet');
});
const formatMoney = (amount: number) => currencyFormatter.value.format(amount);
const formatBytes = (bytes: number) => {
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", "∞")
return t('settings.billing.durationMinutes', { minutes: Math.floor(seconds / 60) });
};
const formatHistoryDate = (value?: string) => {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return shortDateFormatter.value.format(date);
};
const formatTermLabel = (months: number) => t('settings.billing.termOption', { months });
const formatPaymentMethodLabel = (value?: string) => {
switch ((value || '').toLowerCase()) {
case 'topup':
return t('settings.billing.paymentMethod.topup');
case 'wallet':
default:
return t('settings.billing.paymentMethod.wallet');
}
};
const getPlanStorageText = (plan: ModelPlan) => t('settings.billing.planStorage', { storage: formatBytes(plan.storage_limit || 0) });
const getPlanDurationText = (plan: ModelPlan) => t('settings.billing.planDuration', { duration: formatDuration(plan.duration_limit) });
const getPlanUploadsText = (plan: ModelPlan) => t('settings.billing.planUploads', { count: plan.upload_limit || 0 });
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const getStatusLabel = (status: string) => {
const map: Record<string, string> = {
success: t('settings.billing.status.success'),
failed: t('settings.billing.status.failed'),
pending: t('settings.billing.status.pending'),
};
return map[status] || status;
};
const normalizeHistoryStatus = (status?: string) => {
switch ((status || '').toLowerCase()) {
case 'success':
case 'succeeded':
case 'paid':
return 'success';
case 'failed':
case 'error':
case 'canceled':
case 'cancelled':
return 'failed';
case 'pending':
case 'processing':
default:
return 'pending';
}
};
const getApiErrorPayload = (error: unknown): ApiErrorPayload | null => {
if (!error || typeof error !== 'object') return null;
const candidate = error as { error?: ApiErrorPayload; data?: ApiErrorPayload; message?: string };
if (candidate.error && typeof candidate.error === 'object') return candidate.error;
if (candidate.data && typeof candidate.data === 'object') return candidate.data;
if (candidate.message) return { message: candidate.message };
return null;
};
const getApiErrorMessage = (error: unknown, fallback: string) => {
const payload = getApiErrorPayload(error);
return payload?.message || fallback;
};
const getApiErrorData = (error: unknown) => getApiErrorPayload(error)?.data || null;
const mapHistoryItem = (item: PaymentHistoryApiItem): PaymentHistoryItem => {
const details: string[] = [];
if (item.kind !== 'wallet_topup' && item.term_months) {
details.push(formatTermLabel(item.term_months));
}
if (item.kind !== 'wallet_topup' && item.payment_method) {
details.push(formatPaymentMethodLabel(item.payment_method));
}
if (item.kind !== 'wallet_topup' && item.expires_at) {
details.push(t('settings.billing.history.validUntil', { date: formatHistoryDate(item.expires_at) }));
}
return {
id: item.id || '',
date: formatHistoryDate(item.created_at),
amount: item.amount || 0,
plan: item.kind === 'wallet_topup'
? t('settings.billing.walletTopup')
: (item.plan_name || t('settings.billing.unknownPlan')),
status: normalizeHistoryStatus(item.status),
invoiceId: item.invoice_id || '-',
currency: item.currency || 'USD',
kind: item.kind || 'subscription',
details,
};
};
const loadPaymentHistory = async () => {
historyLoading.value = true;
try {
const response = await client.payments.historyList({ baseUrl: '/r' });
const body = response.data as PaymentHistoryEnvelope | undefined;
paymentHistory.value = (body?.data?.payments || []).map(mapHistoryItem);
} catch (error) {
console.error(error);
paymentHistory.value = [];
} finally {
historyLoading.value = false;
}
};
const refetchUsageSnapshot = () => refetchUsage((fetchError) => {
throw fetchError;
});
const refreshBillingState = async () => {
await Promise.allSettled([
auth.fetchMe(),
loadPaymentHistory(),
refetchUsageSnapshot(),
]);
};
void loadPaymentHistory();
const subscriptionSummary = computed(() => {
const expiresAt = auth.user?.plan_expires_at;
const formattedDate = formatHistoryDate(expiresAt);
if (auth.user?.plan_id) {
if (auth.user?.plan_expiring_soon && expiresAt) {
return {
title: t('settings.billing.subscription.expiringTitle'),
description: t('settings.billing.subscription.expiringDescription', {
plan: currentPlanName.value,
date: formattedDate,
}),
tone: 'warning' as const,
};
}
if (expiresAt) {
return {
title: t('settings.billing.subscription.activeTitle'),
description: t('settings.billing.subscription.activeDescription', {
plan: currentPlanName.value,
date: formattedDate,
}),
tone: 'default' as const,
};
}
return {
title: t('settings.billing.subscription.activeTitle'),
description: currentPlanName.value,
tone: 'default' as const,
};
}
if (expiresAt) {
return {
title: t('settings.billing.subscription.expiredTitle'),
description: t('settings.billing.subscription.expiredDescription', { date: formattedDate }),
tone: 'warning' as const,
};
}
return {
title: t('settings.billing.subscription.freeTitle'),
description: t('settings.billing.subscription.freeDescription'),
tone: 'default' as const,
};
});
const resetUpgradeState = () => {
selectedPlan.value = null;
selectedTermMonths.value = 1;
selectedPaymentMethod.value = 'wallet';
purchaseTopupAmount.value = null;
purchaseError.value = null;
};
const openUpgradeDialog = (plan: ModelPlan) => {
selectedPlan.value = plan;
selectedTermMonths.value = 1;
purchaseError.value = null;
selectedPaymentMethod.value = walletBalance.value >= (plan.price || 0) ? 'wallet' : 'topup';
purchaseTopupAmount.value = null;
upgradeDialogVisible.value = true;
};
const closeUpgradeDialog = () => {
if (purchaseLoading.value) return;
upgradeDialogVisible.value = false;
resetUpgradeState();
};
const onUpgradeDialogVisibilityChange = (visible: boolean) => {
if (visible) {
upgradeDialogVisible.value = true;
return;
}
closeUpgradeDialog();
};
watch(selectedShortfall, (value) => {
if (!upgradeDialogVisible.value) return;
if (value <= 0) {
selectedPaymentMethod.value = 'wallet';
return;
}
if (selectedPaymentMethod.value === 'topup' && ((purchaseTopupAmount.value || 0) < value)) {
purchaseTopupAmount.value = Number(value.toFixed(2));
}
});
const selectUpgradePaymentMethod = (method: UpgradePaymentMethod) => {
selectedPaymentMethod.value = method;
purchaseError.value = null;
if (method === 'topup' && selectedShortfall.value > 0 && ((purchaseTopupAmount.value || 0) < selectedShortfall.value)) {
purchaseTopupAmount.value = Number(selectedShortfall.value.toFixed(2));
}
};
const updatePurchaseTopupAmount = (value: string | number | null) => {
if (typeof value === 'number' || value === null) {
purchaseTopupAmount.value = value;
return;
}
if (value === '') {
purchaseTopupAmount.value = null;
return;
}
const parsed = Number(value);
purchaseTopupAmount.value = Number.isNaN(parsed) ? null : parsed;
};
const submitUpgrade = async () => {
if (!selectedPlan.value?.id) return;
purchaseLoading.value = true;
purchaseError.value = null;
try {
const paymentMethod: UpgradePaymentMethod = selectedNeedsTopup.value ? selectedPaymentMethod.value : 'wallet';
const payload: Record<string, any> = {
plan_id: selectedPlan.value.id,
term_months: selectedTermMonths.value,
payment_method: paymentMethod,
};
if (paymentMethod === 'topup') {
payload.topup_amount = purchaseTopupAmount.value || selectedShortfall.value;
}
await client.payments.paymentsCreate(payload, { baseUrl: '/r' });
await refreshBillingState();
toast.add({
severity: 'success',
summary: t('settings.billing.toast.subscriptionSuccessSummary'),
detail: t('settings.billing.toast.subscriptionSuccessDetail', {
plan: selectedPlan.value.name || '',
term: formatTermLabel(selectedTermMonths.value),
}),
life: 3000,
});
closeUpgradeDialog();
} catch (error) {
console.error(error);
const errorData = getApiErrorData(error);
const nextShortfall = typeof errorData?.shortfall === 'number'
? errorData.shortfall
: selectedShortfall.value;
if (nextShortfall > 0) {
selectedPaymentMethod.value = 'topup';
if ((purchaseTopupAmount.value || 0) < nextShortfall) {
purchaseTopupAmount.value = Number(nextShortfall.toFixed(2));
}
}
purchaseError.value = getApiErrorMessage(error, t('settings.billing.toast.subscriptionFailedDetail'));
} finally {
purchaseLoading.value = false;
}
};
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
await client.wallet.topupsCreate({ amount }, { baseUrl: '/r' });
await refreshBillingState();
toast.add({
severity: 'success',
summary: t('settings.billing.toast.topupSuccessSummary'),
detail: t('settings.billing.toast.topupSuccessDetail', { amount: formatMoney(amount) }),
life: 3000,
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (error) {
console.error(error);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.topupFailedSummary'),
detail: getApiErrorMessage(error, t('settings.billing.toast.topupFailedDetail')),
life: 5000,
});
} finally {
topupLoading.value = false;
}
};
const handleDownloadInvoice = async (item: PaymentHistoryItem) => {
if (!item.id) return;
downloadingInvoiceId.value = item.id;
toast.add({
severity: 'info',
summary: t('settings.billing.toast.downloadingSummary'),
detail: t('settings.billing.toast.downloadingDetail', { invoiceId: item.invoiceId }),
life: 2000,
});
try {
const response = await client.payments.invoiceList(item.id, { baseUrl: '/r', format: 'text' });
const content = typeof response.data === 'string' ? response.data : '';
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = `${item.invoiceId}.txt`;
document.body.appendChild(anchor);
anchor.click();
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
toast.add({
severity: 'success',
summary: t('settings.billing.toast.downloadedSummary'),
detail: t('settings.billing.toast.downloadedDetail', { invoiceId: item.invoiceId }),
life: 3000,
});
} catch (error) {
console.error(error);
toast.add({
severity: 'error',
summary: t('settings.billing.toast.downloadFailedSummary'),
detail: getApiErrorMessage(error, t('settings.billing.toast.downloadFailedDetail')),
life: 5000,
});
} finally {
downloadingInvoiceId.value = null;
}
};
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.billing.title')"
:description="t('settings.content.billing.subtitle')"
>
<BillingWalletRow
:title="t('settings.billing.walletBalance')"
:description="t('settings.billing.currentBalance', { balance: formatMoney(walletBalance) })"
:button-label="t('settings.billing.topUp')"
:subscription-title="subscriptionSummary.title"
:subscription-description="subscriptionSummary.description"
:subscription-tone="subscriptionSummary.tone"
@topup="openTopupDialog"
/>
<BillingPlansSection
:title="t('settings.billing.availablePlans')"
:description="t('settings.billing.availablePlansHint')"
:is-loading="isLoading"
:plans="plans"
:current-plan-id="currentPlanId"
:selecting-plan-id="selectedPlanId"
:format-money="formatMoney"
:get-plan-storage-text="getPlanStorageText"
:get-plan-duration-text="getPlanDurationText"
:get-plan-uploads-text="getPlanUploadsText"
:current-plan-label="t('settings.billing.currentPlan')"
:selecting-label="t('settings.billing.upgradeDialog.selecting')"
:choose-label="t('settings.billing.upgradeDialog.choosePlan')"
@select="openUpgradeDialog"
/>
<BillingUsageSection
:storage-title="t('settings.billing.storage')"
:storage-description="t('settings.billing.storageUsedOfLimit', { used: formatBytes(storageUsed), limit: formatBytes(storageLimit) })"
:storage-percentage="storagePercentage"
:uploads-title="t('settings.billing.totalVideos')"
:uploads-description="t('settings.billing.totalVideosUsedOfLimit', { used: uploadsUsed, limit: uploadsLimit })"
:uploads-percentage="uploadsPercentage"
/>
<BillingHistorySection
:title="t('settings.billing.paymentHistory')"
:description="t('settings.billing.paymentHistorySubtitle')"
:items="paymentHistory"
:loading="historyLoading"
:downloading-id="downloadingInvoiceId"
:format-money="formatMoney"
:get-status-styles="getStatusStyles"
:get-status-label="getStatusLabel"
:date-label="t('settings.billing.table.date')"
:amount-label="t('settings.billing.table.amount')"
:plan-label="t('settings.billing.table.plan')"
:status-label="t('settings.billing.table.status')"
:invoice-label="t('settings.billing.table.invoice')"
:empty-label="t('settings.billing.noPaymentHistory')"
:download-label="t('settings.billing.download')"
@download="handleDownloadInvoice"
/>
</SettingsSectionCard>
<AppDialog
:visible="upgradeDialogVisible"
:title="t('settings.billing.upgradeDialog.title')"
maxWidthClass="max-w-2xl"
@update:visible="onUpgradeDialogVisibilityChange"
@close="closeUpgradeDialog"
>
<div v-if="selectedPlan" class="space-y-5">
<div class="rounded-lg border border-border bg-muted/20 p-4">
<div class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<p class="text-xs font-medium uppercase tracking-[0.18em] text-foreground/50">
{{ t('settings.billing.upgradeDialog.selectedPlan') }}
</p>
<h3 class="mt-1 text-lg font-semibold text-foreground">{{ selectedPlan.name }}</h3>
<p class="mt-1 text-sm text-foreground/70">
{{ selectedPlan.description || t('settings.billing.availablePlansHint') }}
</p>
</div>
<div class="text-left md:text-right">
<p class="text-xs text-foreground/50">{{ t('settings.billing.upgradeDialog.basePrice') }}</p>
<p class="mt-1 text-2xl font-semibold text-foreground">{{ formatMoney(selectedPlan.price || 0) }}</p>
<p class="text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.perMonthBase') }}</p>
</div>
</div>
</div>
<div class="space-y-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.termTitle') }}</p>
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.termHint') }}</p>
</div>
<div class="grid grid-cols-2 gap-3 md:grid-cols-4">
<button
v-for="months in TERM_OPTIONS"
:key="months"
type="button"
:class="[
'rounded-lg border px-4 py-3 text-left transition-all',
selectedTermMonths === months
? 'border-primary bg-primary/5 text-primary'
: 'border-border bg-surface text-foreground hover:border-primary/30 hover:bg-muted/30',
]"
@click="selectedTermMonths = months"
>
<p class="text-sm font-medium">{{ formatTermLabel(months) }}</p>
<p class="mt-1 text-xs text-foreground/60">{{ formatMoney((selectedPlan.price || 0) * months) }}</p>
</button>
</div>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded-lg border border-border bg-surface p-4">
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.totalLabel') }}</p>
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(selectedTotalAmount) }}</p>
</div>
<div class="rounded-lg border border-border bg-surface p-4">
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.walletBalanceLabel') }}</p>
<p class="mt-2 text-xl font-semibold text-foreground">{{ formatMoney(walletBalance) }}</p>
</div>
<div
class="rounded-lg border p-4"
:class="selectedNeedsTopup
? 'border-warning/30 bg-warning/10'
: 'border-success/20 bg-success/5'"
>
<p class="text-xs uppercase tracking-wide text-foreground/50">{{ t('settings.billing.upgradeDialog.shortfallLabel') }}</p>
<p class="mt-2 text-xl font-semibold" :class="selectedNeedsTopup ? 'text-warning' : 'text-success'">
{{ formatMoney(selectedShortfall) }}
</p>
</div>
</div>
<div v-if="selectedNeedsTopup" class="space-y-3">
<div>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.paymentMethodTitle') }}</p>
<p class="mt-1 text-xs text-foreground/60">{{ t('settings.billing.upgradeDialog.paymentMethodHint') }}</p>
</div>
<div class="grid gap-3 md:grid-cols-2">
<button
type="button"
:class="[
'rounded-lg border p-4 text-left transition-all',
selectedPaymentMethod === 'wallet'
? 'border-primary bg-primary/5'
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
]"
@click="selectUpgradePaymentMethod('wallet')"
>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.wallet') }}</p>
<p class="mt-1 text-xs text-foreground/60">
{{ t('settings.billing.upgradeDialog.walletOptionDescription') }}
</p>
</button>
<button
type="button"
:class="[
'rounded-lg border p-4 text-left transition-all',
selectedPaymentMethod === 'topup'
? 'border-primary bg-primary/5'
: 'border-border bg-surface hover:border-primary/30 hover:bg-muted/30',
]"
@click="selectUpgradePaymentMethod('topup')"
>
<p class="text-sm font-medium text-foreground">{{ t('settings.billing.paymentMethod.topup') }}</p>
<p class="mt-1 text-xs text-foreground/60">
{{ t('settings.billing.upgradeDialog.topupOptionDescription', { shortfall: formatMoney(selectedShortfall) }) }}
</p>
</button>
</div>
</div>
<div v-else class="rounded-lg border border-success/20 bg-success/5 p-4 text-sm text-success">
{{ t('settings.billing.upgradeDialog.walletCoveredHint') }}
</div>
<div v-if="selectedNeedsTopup && selectedPaymentMethod === 'topup'" class="grid gap-2">
<label class="text-sm font-medium text-foreground">{{ t('settings.billing.upgradeDialog.topupAmountLabel') }}</label>
<AppInput
:model-value="purchaseTopupAmount"
type="number"
min="0.01"
step="0.01"
:placeholder="t('settings.billing.upgradeDialog.topupAmountPlaceholder')"
@update:model-value="updatePurchaseTopupAmount"
/>
<p class="text-xs text-foreground/60">
{{ t('settings.billing.upgradeDialog.topupAmountHint', { shortfall: formatMoney(selectedShortfall) }) }}
</p>
</div>
<div
v-if="selectedNeedsTopup && selectedPaymentMethod === 'wallet'"
class="rounded-lg border border-warning/30 bg-warning/10 p-4 text-sm text-warning"
>
{{ t('settings.billing.upgradeDialog.walletInsufficientHint', { shortfall: formatMoney(selectedShortfall) }) }}
</div>
<div v-if="purchaseError" class="rounded-lg border border-danger bg-danger/10 p-4 text-sm text-danger">
{{ purchaseError }}
</div>
</div>
<template #footer>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-foreground/60">
{{ t('settings.billing.upgradeDialog.footerHint') }}
</p>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="purchaseLoading"
@click="closeUpgradeDialog"
>
{{ t('common.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="purchaseLoading"
:disabled="!canSubmitUpgrade"
@click="submitUpgrade"
>
{{ upgradeSubmitLabel }}
</AppButton>
</div>
</div>
</template>
</AppDialog>
<BillingTopupDialog
:visible="topupDialogVisible"
:title="t('settings.billing.topupDialog.title')"
:subtitle="t('settings.billing.topupDialog.subtitle')"
:presets="topupPresets"
:amount="topupAmount"
:loading="topupLoading"
:custom-amount-label="t('settings.billing.topupDialog.customAmount')"
:amount-placeholder="t('settings.billing.topupDialog.enterAmount')"
:hint="t('settings.billing.topupDialog.hint')"
:cancel-label="t('common.cancel')"
:proceed-label="t('settings.billing.topupDialog.proceed')"
:format-money="formatMoney"
@update:visible="topupDialogVisible = $event"
@update:amount="topupAmount = $event"
@selectPreset="selectPreset"
@submit="handleTopup(topupAmount || 0)"
/>
</template>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { client } from '@/api/client';
import AppButton from '@/components/app/AppButton.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import SettingsNotice from '@/routes/settings/components/SettingsNotice.vue';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
const auth = useAuthStore();
const router = useRouter();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t } = useTranslation();
const deletingAccount = ref(false);
const clearingData = ref(false);
const handleDeleteAccount = () => {
confirm.require({
message: t('settings.dangerZone.confirm.deleteAccountMessage'),
header: t('settings.dangerZone.confirm.deleteAccountHeader'),
acceptLabel: t('settings.dangerZone.confirm.deleteAccountAccept'),
rejectLabel: t('settings.dangerZone.confirm.deleteAccountReject'),
accept: async () => {
deletingAccount.value = true;
try {
await client.me.deleteMe({ baseUrl: '/r' });
auth.$reset();
toast.add({
severity: 'success',
summary: t('settings.dangerZone.toast.deleteAccountSummary'),
detail: t('settings.dangerZone.toast.deleteAccountDetail'),
life: 5000,
});
await router.push('/login');
} catch (e: any) {
console.error(e);
toast.add({
severity: 'error',
summary: t('settings.dangerZone.toast.failedSummary'),
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
life: 5000,
});
} finally {
deletingAccount.value = false;
}
},
});
};
const handleClearData = () => {
confirm.require({
message: t('settings.dangerZone.confirm.clearDataMessage'),
header: t('settings.dangerZone.confirm.clearDataHeader'),
acceptLabel: t('settings.dangerZone.confirm.clearDataAccept'),
rejectLabel: t('settings.dangerZone.confirm.clearDataReject'),
accept: async () => {
clearingData.value = true;
try {
await client.me.clearDataCreate({ baseUrl: '/r' });
await auth.fetchMe();
toast.add({
severity: 'success',
summary: t('settings.dangerZone.toast.clearDataSummary'),
detail: t('settings.dangerZone.toast.clearDataDetail'),
life: 5000,
});
} catch (e: any) {
console.error(e);
toast.add({
severity: 'error',
summary: t('settings.dangerZone.toast.failedSummary'),
detail: e.message || t('settings.dangerZone.toast.failedDetail'),
life: 5000,
});
} finally {
clearingData.value = false;
}
},
});
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.danger.title')"
:description="t('settings.content.danger.subtitle')"
titleClass="text-base font-semibold text-danger"
>
<SettingsRow
:title="t('settings.dangerZone.deleteAccount.title')"
:description="t('settings.dangerZone.deleteAccount.description')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" :loading="deletingAccount" :disabled="clearingData" @click="handleDeleteAccount">
<template #icon>
<TrashIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.deleteAccount.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.dangerZone.clearData.title')"
:description="t('settings.dangerZone.clearData.description')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</template>
<template #actions>
<AppButton variant="danger" size="sm" :loading="clearingData" :disabled="deletingAccount" @click="handleClearData">
<template #icon>
<SlidersIcon class="w-4 h-4" />
</template>
{{ t('settings.dangerZone.clearData.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsNotice tone="warning" class="mx-6 my-4 border-warning/30">
<p class="font-medium text-foreground mb-1">{{ t('settings.dangerZone.warning.title') }}</p>
<p>{{ t('settings.dangerZone.warning.description') }}</p>
</SettingsNotice>
</SettingsSectionCard>
</template>

View File

@@ -0,0 +1,353 @@
<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 { 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';
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);
},
});
const domains = computed(() => domainsSnapshot.value || []);
const isInitialLoading = computed(() => isPending.value && !domainsSnapshot.value);
const iframeCode = computed(() => '<iframe src="https://holistream.com/embed" width="100%" height="500" frameborder="0" allowfullscreen></iframe>');
const refetchDomains = () => refetch((fetchError) => {
throw fetchError;
});
watch(error, (value, previous) => {
if (!value || value === previous || adding.value || removingId.value !== null) return;
toast.add({
severity: 'error',
summary: t('settings.domainsDns.toast.failedSummary'),
detail: (value as any)?.message || t('settings.domainsDns.toast.failedDetail'),
life: 5000,
});
});
const openAddDialog = () => {
newDomain.value = '';
showAddDialog.value = true;
};
const closeAddDialog = () => {
showAddDialog.value = false;
newDomain.value = '';
};
const handleAddDomain = async () => {
if (adding.value) return;
const domainName = normalizeDomainInput(newDomain.value);
if (!domainName || !domainName.includes('.') || /[\/\s]/.test(domainName)) {
toast.add({
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 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;
}
},
});
};
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);
}
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>
<SettingsNotice class="rounded-none border-x-0 border-t-0 p-3" contentClass="text-xs text-foreground/70">
{{ t('settings.domainsDns.infoBanner') }}
</SettingsNotice>
<SettingsTableSkeleton v-if="isInitialLoading" :columns="3" :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.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>
<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>
</template>

View File

@@ -0,0 +1,157 @@
<script setup lang="ts">
import { client } from '@/api/client';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import BellIcon from '@/components/icons/BellIcon.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import MailIcon from '@/components/icons/MailIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import {
createNotificationSettingsDraft,
toNotificationPreferencesPayload,
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';
const toast = useAppToast();
const { t } = useTranslation();
const { data: preferencesSnapshot, error, isPending, refetch } = useSettingsPreferencesQuery();
const notificationSettings = ref(createNotificationSettingsDraft());
const saving = ref(false);
const notificationTypes = computed(() => [
{
key: 'email' as const,
title: t('settings.notificationSettings.types.email.title'),
description: t('settings.notificationSettings.types.email.description'),
icon: MailIcon,
bgColor: 'bg-primary/10',
iconColor: 'text-primary',
},
{
key: 'push' as const,
title: t('settings.notificationSettings.types.push.title'),
description: t('settings.notificationSettings.types.push.description'),
icon: BellIcon,
bgColor: 'bg-accent/10',
iconColor: 'text-accent',
},
{
key: 'marketing' as const,
title: t('settings.notificationSettings.types.marketing.title'),
description: t('settings.notificationSettings.types.marketing.description'),
icon: SendIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
{
key: 'telegram' as const,
title: t('settings.notificationSettings.types.telegram.title'),
description: t('settings.notificationSettings.types.telegram.description'),
icon: TelegramIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
]);
const isInitialLoading = computed(() => isPending.value && !preferencesSnapshot.value);
const isInteractionDisabled = computed(() => saving.value || isInitialLoading.value || !preferencesSnapshot.value);
const refetchPreferences = () => refetch((fetchError) => {
throw fetchError;
});
watch(preferencesSnapshot, (snapshot) => {
if (!snapshot) return;
notificationSettings.value = createNotificationSettingsDraft(snapshot);
}, { immediate: true });
watch(error, (value, previous) => {
if (!value || value === previous || saving.value) return;
toast.add({
severity: 'error',
summary: t('settings.notificationSettings.toast.failedSummary'),
detail: (value as any)?.message || t('settings.notificationSettings.toast.failedDetail'),
life: 5000,
});
});
const handleSave = async () => {
if (saving.value || !preferencesSnapshot.value) return;
saving.value = true;
try {
await client.settings.preferencesUpdate(
toNotificationPreferencesPayload(notificationSettings.value),
{ baseUrl: '/r' },
);
await refetchPreferences();
toast.add({
severity: 'success',
summary: t('settings.notificationSettings.toast.savedSummary'),
detail: t('settings.notificationSettings.toast.savedDetail'),
life: 3000,
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: t('settings.notificationSettings.toast.failedSummary'),
detail: e.message || t('settings.notificationSettings.toast.failedDetail'),
life: 5000,
});
} finally {
saving.value = false;
}
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.content.notifications.title')"
:description="t('settings.content.notifications.subtitle')"
>
<template #header-actions>
<AppButton size="sm" :loading="saving" :disabled="isInitialLoading || !preferencesSnapshot" @click="handleSave">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.notificationSettings.saveChanges') }}
</AppButton>
</template>
<template v-if="isInitialLoading">
<SettingsRowSkeleton
v-for="type in notificationTypes"
:key="type.key"
/>
</template>
<template v-else>
<SettingsRow
v-for="type in notificationTypes"
:key="type.key"
:title="type.title"
:description="type.description"
:iconBoxClass="type.bgColor"
>
<template #icon>
<component :is="type.icon" :class="[type.iconColor, 'w-5 h-5']" />
</template>
<template #actions>
<AppSwitch v-model="notificationSettings[type.key]" :disabled="isInteractionDisabled" />
</template>
</SettingsRow>
</template>
</SettingsSectionCard>
</template>

View File

@@ -0,0 +1,167 @@
<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';
const toast = useAppToast();
const { t } = useTranslation();
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,
});
});
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>
</template>

View File

@@ -0,0 +1,517 @@
<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 AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { supportedLocales } from '@/i18n/constants';
import SettingsRow from '@/routes/settings/components/SettingsRow.vue';
import SettingsSectionCard from '@/routes/settings/components/SettingsSectionCard.vue';
import { useAuthStore } from '@/stores/auth';
import { useTranslation } from 'i18next-vue';
import { computed, ref } from 'vue';
const auth = useAuthStore();
const toast = useAppToast();
const confirm = useAppConfirm();
const { t, i18next } = useTranslation();
const languageSaving = ref(false);
const selectedLanguage = ref<string>(auth.user?.language || "en");
const languageOptions = computed(() => supportedLocales.map((value) => ({
value,
label: t(`settings.securityConnected.language.options.${value}`)
})));
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
const changePasswordError = ref('');
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.mismatch');
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = t('settings.securityConnected.changePassword.dialog.errors.minLength');
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.changePassword.toast.successSummary'),
detail: t('settings.securityConnected.changePassword.toast.successDetail'),
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || t('settings.securityConnected.changePassword.dialog.errors.default');
} finally {
changePasswordLoading.value = false;
}
};
const handleLogout = () => {
confirm.require({
message: t('settings.securityConnected.logout.confirm.message'),
header: t('settings.securityConnected.logout.confirm.header'),
acceptLabel: t('settings.securityConnected.logout.confirm.accept'),
rejectLabel: t('settings.securityConnected.logout.confirm.reject'),
accept: async () => {
await auth.logout();
}
});
};
const saveLanguage = async () => {
languageSaving.value = true;
try {
const result = await auth.setLanguage(selectedLanguage.value);
if (result.ok && !result.fallbackOnly) {
toast.add({
severity: 'success',
summary: t('settings.securityConnected.language.toast.successSummary'),
detail: t('settings.securityConnected.language.toast.successDetail'),
life: 3000,
});
return;
}
toast.add({
severity: 'warn',
summary: t('settings.securityConnected.language.toast.errorSummary'),
detail: t('settings.securityConnected.language.toast.errorDetail'),
life: 5000,
});
} finally {
languageSaving.value = false;
}
};
const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorEnableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorDisabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorDisableFailedSummary'),
detail: t('settings.securityConnected.toast.twoFactorDisableFailedDetail'),
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.twoFactorEnabledSummary'),
detail: t('settings.securityConnected.toast.twoFactorEnabledDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.twoFactorInvalidCodeSummary'),
detail: t('settings.securityConnected.toast.twoFactorInvalidCodeDetail'),
life: 5000
});
}
};
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: t('settings.securityConnected.toast.telegramConnectedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectedDetail', { username: telegramUsername.value }),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramConnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramConnectFailedDetail'),
life: 5000
});
}
};
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: t('settings.securityConnected.toast.telegramDisconnectedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectedDetail'),
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: t('settings.securityConnected.toast.telegramDisconnectFailedSummary'),
detail: t('settings.securityConnected.toast.telegramDisconnectFailedDetail'),
life: 5000
});
}
};
</script>
<template>
<SettingsSectionCard
:title="t('settings.securityConnected.header.title')"
:description="t('settings.securityConnected.header.subtitle')"
>
<SettingsRow
:title="t('settings.securityConnected.accountStatus.label')"
:description="t('settings.securityConnected.accountStatus.detail')"
iconBoxClass="bg-success/10"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</template>
<template #actions>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">{{ t('settings.securityConnected.accountStatus.badge') }}</span>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.language.label')"
:description="t('settings.securityConnected.language.detail')"
iconBoxClass="bg-info/10"
actionsClass="flex items-center gap-2"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15 15 0 0 1 0 20" />
<path d="M12 2a15 15 0 0 0 0 20" />
</svg>
</template>
<template #actions>
<select
v-model="selectedLanguage"
:disabled="languageSaving"
class="rounded-md border border-border bg-surface px-3 py-2 text-sm text-foreground disabled:opacity-60"
>
<option
v-for="option in languageOptions"
:key="option.value"
:value="option.value"
>
{{ option.label }}
</option>
</select>
<AppButton
size="sm"
:loading="languageSaving"
:disabled="languageSaving"
@click="saveLanguage"
>
{{ t('settings.securityConnected.language.save') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.twoFactor.label')"
:description="twoFactorEnabled ? t('settings.securityConnected.twoFactor.enabled') : t('settings.securityConnected.twoFactor.disabled')"
iconBoxClass="bg-primary/10"
>
<template #icon>
<LockIcon class="w-5 h-5 text-primary" />
</template>
<template #actions>
<AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.changePassword.label')"
:description="t('settings.securityConnected.changePassword.detail')"
iconBoxClass="bg-primary/10"
>
<template #icon>
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</template>
<template #actions>
<AppButton size="sm" @click="openChangePassword">
{{ t('settings.securityConnected.changePassword.button') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.email.label')"
:description="emailConnected ? t('settings.securityConnected.email.connected') : t('settings.securityConnected.email.disconnected')"
iconBoxClass="bg-info/10"
>
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</template>
<template #actions>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? t('settings.securityConnected.email.badgeConnected') : t('settings.securityConnected.email.badgeDisconnected') }}
</span>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.telegram.label')"
:description="telegramConnected ? (telegramUsername || t('settings.securityConnected.telegram.connectedFallback')) : t('settings.securityConnected.telegram.detailDisconnected')"
iconBoxClass="bg-[#0088cc]/10"
>
<template #icon>
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</template>
<template #actions>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="disconnectTelegram"
>
{{ t('settings.securityConnected.telegram.disconnect') }}
</AppButton>
<AppButton
v-else
size="sm"
@click="connectTelegram"
>
{{ t('settings.securityConnected.telegram.connect') }}
</AppButton>
</template>
</SettingsRow>
<SettingsRow
:title="t('settings.securityConnected.logout.label')"
:description="t('settings.securityConnected.logout.detail')"
iconBoxClass="bg-danger/10"
hoverClass="hover:bg-danger/5"
>
<template #icon>
<XCircleIcon class="w-5 h-5 text-danger" />
</template>
<template #actions>
<AppButton variant="danger" size="sm" @click="handleLogout">
<template #icon>
<XCircleIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.logout.button') }}
</AppButton>
</template>
</SettingsRow>
</SettingsSectionCard>
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
:title="t('settings.securityConnected.twoFactorDialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.twoFactorDialog.subtitle') }}
</p>
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">{{ t('settings.securityConnected.twoFactorDialog.secret') }}</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.twoFactorDialog.codeLabel') }}</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
:placeholder="t('settings.securityConnected.twoFactorDialog.codePlaceholder')"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
{{ t('settings.securityConnected.twoFactorDialog.cancel') }}
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.twoFactorDialog.verify') }}
</AppButton>
</div>
</template>
</AppDialog>
<AppDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
:title="t('settings.securityConnected.changePassword.dialog.title')"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ t('settings.securityConnected.changePassword.dialog.subtitle') }}
</p>
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.current') }}</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.currentPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.new') }}</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.newPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">{{ t('settings.securityConnected.changePassword.dialog.confirm') }}</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
:placeholder="t('settings.securityConnected.changePassword.dialog.confirmPlaceholder')"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
{{ t('settings.securityConnected.changePassword.dialog.cancel') }}
</AppButton>
<AppButton
size="sm"
:loading="changePasswordLoading"
@click="changePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ t('settings.securityConnected.changePassword.dialog.submit') }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

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

View File

@@ -1,191 +0,0 @@
<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 CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const props = defineProps<{
dialogVisible: boolean;
error: string;
loading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
emailConnected: boolean;
telegramConnected: boolean;
telegramUsername: string;
}>();
const emit = defineEmits<{
(e: 'update:dialogVisible', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'close'): void;
(e: 'change-password'): void;
(e: 'connect-telegram'): void;
(e: 'disconnect-telegram'): void;
}>();
const handleChangePassword = () => {
emit('change-password');
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h3 class="text-sm font-semibold text-foreground mb-3">Connected Accounts</h3>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Email Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<!-- Telegram Connection -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
</p>
</div>
</div>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="$emit('disconnect-telegram')"
>
Disconnect
</AppButton>
<AppButton
v-else
size="sm"
@click="$emit('connect-telegram')"
>
Connect
</AppButton>
</div>
</div>
<!-- Change Password Dialog -->
<AppDialog
:visible="dialogVisible"
@update:visible="$emit('update:dialogVisible', $event)"
title="Change Password"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
</p>
<!-- Error Message -->
<div v-if="error" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ error }}
</div>
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<AppInput
id="currentPassword"
:model-value="currentPassword"
type="password"
placeholder="Enter current password"
@update:model-value="$emit('update:currentPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<AppInput
id="newPassword"
:model-value="newPassword"
type="password"
placeholder="Enter new password"
@update:model-value="$emit('update:newPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<AppInput
id="confirmPassword"
:model-value="confirmPassword"
type="password"
placeholder="Confirm new password"
@update:model-value="$emit('update:confirmPassword', $event)"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="loading"
@click="$emit('close')"
>
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
</AppButton>
<AppButton
size="sm"
:loading="loading"
@click="handleChangePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -1,151 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/stores/auth';
import AppButton from '@/components/app/AppButton.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppProgressBar from '@/components/app/AppProgressBar.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import MailIcon from '@/components/icons/MailIcon.vue';
import PencilIcon from '@/components/icons/PencilIcon.vue';
import UserIcon from '@/components/icons/UserIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const auth = useAuthStore();
const props = defineProps<{
editing: boolean;
username: string;
email: string;
saving: boolean;
}>();
const emit = defineEmits<{
(e: 'update:username', value: string): void;
(e: 'update:email', value: string): void;
(e: 'start-edit'): void;
(e: 'cancel-edit'): void;
(e: 'save'): void;
(e: 'change-password'): void;
}>();
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
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));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Profile Information</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your personal information and account details.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-6">
<!-- User Avatar & Name -->
<div class="flex items-center gap-4 pb-4 border-b border-border">
<div class="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<UserIcon class="w-8 h-8 text-primary" :filled="true" />
</div>
<div>
<h3 class="text-lg font-semibold text-foreground">{{ auth.user?.username || 'User' }}</h3>
<p class="text-sm text-foreground/60">{{ auth.user?.email || '' }}</p>
</div>
</div>
<!-- Form Fields -->
<div class="grid gap-6 max-w-2xl">
<div class="grid gap-2">
<label for="username" class="text-sm font-medium text-foreground">Username</label>
<AppInput
id="username"
:model-value="username"
:readonly="!editing"
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:username', String($event))"
>
<template #prefix>
<UserIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<div class="grid gap-2">
<label for="email" class="text-sm font-medium text-foreground">Email Address</label>
<AppInput
id="email"
:model-value="email"
:readonly="!editing"
:inputClass="editing ? 'bg-surface' : 'bg-muted/30'"
@update:model-value="emit('update:email', $event || '')"
>
<template #prefix>
<MailIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<!-- Storage Usage -->
<div class="pt-4 border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-accent" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="17 8 12 3 7 8"/>
<line x1="12" x2="12" y1="3" y2="15"/>
</svg>
</div>
<div class="flex-1">
<p class="text-sm font-medium text-foreground">Storage Usage</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
</div>
<span class="text-sm font-semibold text-foreground">{{ storagePercentage }}%</span>
</div>
<AppProgressBar :value="storagePercentage" />
</div>
</div>
<!-- Footer -->
<div class="px-6 py-4 bg-muted/30 border-t border-border flex items-center gap-3">
<template v-if="editing">
<AppButton size="sm" :loading="saving" @click="emit('save')">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
</AppButton>
<AppButton variant="secondary" size="sm" :disabled="saving" @click="emit('cancel-edit')">
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
</AppButton>
</template>
<template v-else>
<AppButton size="sm" @click="emit('start-edit')">
<template #icon>
<PencilIcon class="w-4 h-4" />
</template>
Edit Profile
</AppButton>
<AppButton variant="secondary" size="sm" @click="emit('change-password')">
Change Password
</AppButton>
</template>
</div>
</div>
</template>

View File

@@ -1,181 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import LockIcon from '@/components/icons/LockIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
const props = defineProps<{
twoFactorEnabled: boolean;
changePasswordError: string;
changePasswordLoading: boolean;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}>();
const emit = defineEmits<{
(e: 'update:twoFactorEnabled', value: boolean): void;
(e: 'update:currentPassword', value: string): void;
(e: 'update:newPassword', value: string): void;
(e: 'update:confirmPassword', value: string): void;
(e: 'toggle-2fa'): void;
(e: 'change-password'): void;
(e: 'close-password-dialog'): void;
(e: 'close-2fa-dialog'): void;
(e: 'confirm-2fa'): void;
}>();
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
const handleToggle2FA = async () => {
if (!props.twoFactorEnabled) {
twoFactorDialogVisible.value = true;
} else {
emit('toggle-2fa');
}
};
const confirmTwoFactor = async () => {
emit('confirm-2fa');
twoFactorDialogVisible.value = false;
twoFactorCode.value = '';
};
// (kept minimal; no dynamic items list needed)
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
</p>
</div>
<!-- Content -->
<div class="p-6 space-y-4">
<!-- Account Status -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-success/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
</p>
</div>
</div>
<AppSwitch
:model-value="twoFactorEnabled"
@update:model-value="emit('update:twoFactorEnabled', $event)"
@change="handleToggle2FA"
/>
</div>
<!-- Change Password -->
<div class="flex items-center justify-between p-4 rounded-md bg-muted/30">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<p class="text-xs text-foreground/60 mt-0.5">
Update your account password
</p>
</div>
</div>
<AppButton size="sm" @click="$emit('change-password')">
Change Password
</AppButton>
</div>
</div>
<!-- 2FA Setup Dialog -->
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
title="Enable Two-Factor Authentication"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
<template #icon>
<XIcon class="w-4 h-4" />
</template>
Cancel
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Verify & Enable
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
type Tone = 'info' | 'warning';
const props = withDefaults(defineProps<{
tone?: Tone;
title?: string;
contentClass?: string;
titleClass?: string;
iconClass?: string;
}>(), {
tone: 'info',
title: '',
contentClass: 'text-xs text-foreground/70',
titleClass: 'font-medium text-foreground mb-1',
iconClass: '',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'flex items-start gap-2 rounded-md border p-4',
props.tone === 'warning' ? 'border-warning/20 bg-warning/5' : 'border-info/20 bg-info/5',
));
const defaultIconClass = computed(() => cn(
'mt-0.5 h-4 w-4 shrink-0',
props.tone === 'warning' ? 'text-warning' : 'text-info',
props.iconClass,
));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<slot name="icon">
<component :is="tone === 'warning' ? AlertTriangleIcon : InfoIcon" :class="defaultIconClass" />
</slot>
<div :class="contentClass">
<p v-if="title" :class="titleClass">{{ title }}</p>
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<{
title: string;
description?: string;
iconBoxClass?: string;
hoverClass?: string;
titleClass?: string;
descriptionClass?: string;
actionsClass?: string;
rowClass?: string;
}>(), {
description: '',
iconBoxClass: '',
hoverClass: 'hover:bg-muted/30',
titleClass: 'text-sm font-medium text-foreground',
descriptionClass: 'text-xs text-foreground/60 mt-0.5',
actionsClass: '',
rowClass: '',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'flex items-center justify-between gap-4 px-6 py-4 transition-all',
props.hoverClass,
props.rowClass,
));
const iconClass = computed(() => cn(
'w-10 h-10 rounded-md flex items-center justify-center shrink-0',
props.iconBoxClass,
));
const actionsWrapperClass = computed(() => cn('shrink-0', props.actionsClass));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<div class="flex min-w-0 items-center gap-4">
<div :class="iconClass">
<slot name="icon" />
</div>
<div class="min-w-0">
<p :class="titleClass">{{ title }}</p>
<p v-if="description" :class="descriptionClass">{{ description }}</p>
</div>
</div>
<div v-if="$slots.actions" :class="actionsWrapperClass">
<slot name="actions" />
</div>
</div>
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
actionClass?: string;
titleClass?: string;
descriptionClass?: string;
}>(), {
actionClass: 'h-6 w-11',
titleClass: 'w-32',
descriptionClass: 'w-56 max-w-full',
});
</script>
<template>
<div class="flex items-center justify-between gap-4 px-6 py-4 animate-pulse">
<div class="flex min-w-0 items-center gap-4">
<div class="h-10 w-10 rounded-md bg-muted/50 shrink-0" />
<div class="min-w-0 space-y-2">
<div :class="cn('h-4 rounded bg-muted/50', titleClass)" />
<div :class="cn('h-3 rounded bg-muted/40', descriptionClass)" />
</div>
</div>
<div :class="cn('shrink-0 rounded-full bg-muted/50', actionClass)" />
</div>
</template>

View File

@@ -0,0 +1,54 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed, useAttrs } from 'vue';
defineOptions({ inheritAttrs: false });
const props = withDefaults(defineProps<{
title?: string;
description?: string;
bodyClass?: string;
headerClass?: string;
titleClass?: string;
descriptionClass?: string;
}>(), {
title: '',
description: '',
bodyClass: 'divide-y divide-border',
headerClass: '',
titleClass: 'text-base font-semibold text-foreground',
descriptionClass: 'text-sm text-foreground/60 mt-0.5',
});
const attrs = useAttrs();
const rootClass = computed(() => cn(
'bg-surface border border-border rounded-lg',
));
</script>
<template>
<div v-bind="attrs" :class="rootClass">
<div
v-if="title || description || $slots['header-actions']"
:class="cn(
'px-6 py-4 border-b border-border',
$slots['header-actions'] ? 'flex items-center justify-between gap-4' : '',
headerClass,
)"
>
<div class="min-w-0">
<h2 v-if="title" :class="titleClass">{{ title }}</h2>
<p v-if="description" :class="descriptionClass">{{ description }}</p>
</div>
<div v-if="$slots['header-actions']" class="shrink-0">
<slot name="header-actions" />
</div>
</div>
<div :class="bodyClass">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,40 @@
<script setup lang="ts">
const props = withDefaults(defineProps<{
columns?: number;
rows?: number;
}>(), {
columns: 3,
rows: 4,
});
</script>
<template>
<div class="border-b border-border mt-4">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th
v-for="column in columns"
:key="column"
class="px-6 py-3"
>
<div class="h-3 w-20 rounded bg-muted/50 animate-pulse" />
</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr v-for="row in rows" :key="row" class="animate-pulse">
<td v-for="column in columns" :key="column" class="px-6 py-4">
<div class="space-y-2">
<div class="h-4 rounded bg-muted/50" :class="column === columns ? 'ml-auto w-16' : 'w-full max-w-[12rem]'" />
<div
v-if="column === 1"
class="h-3 w-24 rounded bg-muted/40"
/>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
type PaymentHistoryItem = {
id: string;
date: string;
amount: number;
plan: string;
status: string;
invoiceId: string;
currency: string;
kind: string;
details?: string[];
};
defineProps<{
title: string;
description: string;
items: PaymentHistoryItem[];
loading?: boolean;
downloadingId?: string | null;
formatMoney: (amount: number) => string;
getStatusStyles: (status: string) => string;
getStatusLabel: (status: string) => string;
dateLabel: string;
amountLabel: string;
planLabel: string;
statusLabel: string;
invoiceLabel: string;
emptyLabel: string;
downloadLabel: string;
}>();
const emit = defineEmits<{
(e: 'download', item: PaymentHistoryItem): void;
}>();
</script>
<template>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">{{ dateLabel }}</div>
<div class="col-span-2">{{ amountLabel }}</div>
<div class="col-span-3">{{ planLabel }}</div>
<div class="col-span-2">{{ statusLabel }}</div>
<div class="col-span-2 text-right">{{ invoiceLabel }}</div>
</div>
<div v-if="loading" class="px-4 py-6 space-y-3">
<div v-for="index in 3" :key="index" class="grid grid-cols-12 gap-4 items-center animate-pulse">
<div class="col-span-3 h-4 rounded bg-muted/50" />
<div class="col-span-2 h-4 rounded bg-muted/50" />
<div class="col-span-3 h-4 rounded bg-muted/50" />
<div class="col-span-2 h-6 rounded bg-muted/50" />
<div class="col-span-2 h-8 rounded bg-muted/50" />
</div>
</div>
<div v-else-if="items.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>{{ emptyLabel }}</p>
</div>
<template v-else>
<div
v-for="item in items"
:key="item.id"
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">{{ formatMoney(item.amount) }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
<p v-if="item.details?.length" class="mt-1 text-xs text-foreground/60">
{{ item.details.join(' · ') }}
</p>
</div>
<div class="col-span-2">
<span :class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`">
{{ getStatusLabel(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all disabled:opacity-60 disabled:cursor-wait"
:disabled="downloadingId === item.id"
@click="emit('download', item)"
>
<DownloadIcon class="w-4 h-4" />
<span>{{ downloadingId === item.id ? '...' : downloadLabel }}</span>
</button>
</div>
</div>
</template>
</div>
</div>
</template>

View File

@@ -0,0 +1,110 @@
<script setup lang="ts">
import type { ModelPlan } from '@/api/client';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
defineProps<{
title: string;
description: string;
isLoading: boolean;
plans: ModelPlan[];
currentPlanId?: string;
selectingPlanId?: string | null;
formatMoney: (amount: number) => string;
getPlanStorageText: (plan: ModelPlan) => string;
getPlanDurationText: (plan: ModelPlan) => string;
getPlanUploadsText: (plan: ModelPlan) => string;
currentPlanLabel: string;
selectingLabel: string;
chooseLabel: string;
}>();
const emit = defineEmits<{
(e: 'select', plan: ModelPlan): void;
}>();
</script>
<template>
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ description }}</p>
</div>
</div>
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<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)"
:key="plan.id"
:class="[
'border rounded-lg p-4 hover:bg-muted/30 transition-all flex flex-col',
plan.id === currentPlanId ? 'border-primary/40 bg-primary/5' : 'border-border',
]"
>
<div class="mb-3">
<div class="flex items-center justify-between gap-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<span
v-if="plan.id === currentPlanId"
class="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-[11px] font-medium text-primary"
>
{{ currentPlanLabel }}
</span>
</div>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">{{ formatMoney(plan.price || 0) }}</span>
<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"
class="flex items-center gap-2 text-foreground/70"
>
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ feature }}
</li>
</ul>
<button
v-if="plan.id !== currentPlanId"
:disabled="selectingPlanId === plan.id"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all mt-a',
selectingPlanId === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-white hover:bg-primary/90'
]"
@click="emit('select', plan)"
>
{{ selectingPlanId === plan.id ? selectingLabel : chooseLabel }}
</button>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,105 @@
<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 CheckIcon from '@/components/icons/CheckIcon.vue';
defineProps<{
visible: boolean;
title: string;
subtitle: string;
presets: number[];
amount: number | null;
loading: boolean;
customAmountLabel: string;
amountPlaceholder: string;
hint: string;
cancelLabel: string;
proceedLabel: string;
formatMoney: (amount: number) => string;
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'update:amount', value: number | null): void;
(e: 'selectPreset', amount: number): void;
(e: 'submit'): void;
}>();
</script>
<template>
<AppDialog
:visible="visible"
@update:visible="emit('update:visible', $event)"
:title="title"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
{{ subtitle }}
</p>
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in presets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
amount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="emit('selectPreset', preset)"
>
{{ formatMoney(preset) }}
</button>
</div>
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">{{ customAmountLabel }}</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<AppInput
:model-value="amount"
type="number"
:placeholder="amountPlaceholder"
inputClass="flex-1"
min="1"
step="1"
@update:model-value="emit('update:amount', typeof $event === 'number' || $event === null
? $event
: ($event === '' ? null : Number($event)))"
/>
</div>
</div>
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>{{ hint }}</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton
variant="secondary"
size="sm"
:disabled="loading"
@click="emit('update:visible', false)"
>
{{ cancelLabel }}
</AppButton>
<AppButton
size="sm"
:loading="loading"
:disabled="!amount || amount < 1 || loading"
@click="emit('submit')"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ proceedLabel }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
defineProps<{
storageTitle: string;
storageDescription: string;
storagePercentage: number;
uploadsTitle: string;
uploadsDescription: string;
uploadsPercentage: number;
}>();
</script>
<template>
<div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-5 h-5 text-accent" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ storageTitle }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ storageDescription }}</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-primary h-full rounded-full transition-all duration-300"
:style="{ width: `${storagePercentage}%` }"
></div>
</div>
</div>
<div class="px-6 py-4 hover:bg-muted/30 transition-all border-t border-border">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ uploadsTitle }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ uploadsDescription }}</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-info h-full rounded-full transition-all duration-300"
:style="{ width: `${uploadsPercentage}%` }"
></div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import SettingsRow from '../SettingsRow.vue';
defineProps<{
title: string;
description: string;
buttonLabel: string;
subscriptionTitle?: string;
subscriptionDescription?: string;
subscriptionTone?: 'default' | 'warning';
}>();
const emit = defineEmits<{
(e: 'topup'): void;
}>();
</script>
<template>
<SettingsRow :title="title" :description="description" iconBoxClass="bg-primary/10">
<template #icon>
<CoinsIcon class="w-5 h-5 text-primary" />
</template>
<template #actions>
<div class="flex flex-col items-end gap-2">
<!-- <div
v-if="subscriptionTitle || subscriptionDescription"
class="rounded-md border px-3 py-2 text-right"
:class="subscriptionTone === 'warning'
? 'border-warning/30 bg-warning/10 text-warning'
: 'border-border bg-muted/30 text-foreground/70'"
>
<p v-if="subscriptionTitle" class="text-xs font-medium">{{ subscriptionTitle }}</p>
<p v-if="subscriptionDescription" class="mt-0.5 text-xs">{{ subscriptionDescription }}</p>
</div> -->
<AppButton size="sm" @click="emit('topup')">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
{{ buttonLabel }}
</AppButton>
</div>
</template>
</SettingsRow>
</template>

View File

@@ -1,348 +0,0 @@
<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 AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.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 { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { ref } from 'vue';
const toast = useAppToast();
const confirm = useAppConfirm();
// VAST Templates
interface VastTemplate {
id: string;
name: string;
vastUrl: string;
adFormat: 'pre-roll' | 'mid-roll' | 'post-roll';
duration?: number;
enabled: boolean;
createdAt: string;
}
const templates = ref<VastTemplate[]>([
{
id: '1',
name: 'Main Pre-roll Ad',
vastUrl: 'https://ads.example.com/vast/pre-roll.xml',
adFormat: 'pre-roll',
enabled: true,
createdAt: '2024-01-10',
},
{
id: '2',
name: 'Mid-roll Ad Break',
vastUrl: 'https://ads.example.com/vast/mid-roll.xml',
adFormat: 'mid-roll',
duration: 30,
enabled: false,
createdAt: '2024-02-15',
},
]);
const showAddDialog = ref(false);
const editingTemplate = ref<VastTemplate | null>(null);
const formData = ref({
name: '',
vastUrl: '',
adFormat: 'pre-roll' as 'pre-roll' | 'mid-roll' | 'post-roll',
duration: undefined as number | undefined,
});
const resetForm = () => {
formData.value = {
name: '',
vastUrl: '',
adFormat: 'pre-roll',
duration: undefined,
};
editingTemplate.value = null;
};
const openAddDialog = () => {
resetForm();
showAddDialog.value = true;
};
const openEditDialog = (template: VastTemplate) => {
formData.value = {
name: template.name,
vastUrl: template.vastUrl,
adFormat: template.adFormat,
duration: template.duration,
};
editingTemplate.value = template;
showAddDialog.value = true;
};
const handleSave = () => {
if (!formData.value.name.trim()) {
toast.add({ severity: 'error', summary: 'Name Required', detail: 'Please enter a template name.', life: 3000 });
return;
}
if (!formData.value.vastUrl.trim()) {
toast.add({ severity: 'error', summary: 'VAST URL Required', detail: 'Please enter the VAST tag URL.', life: 3000 });
return;
}
try {
new URL(formData.value.vastUrl);
} catch {
toast.add({ severity: 'error', summary: 'Invalid URL', detail: 'Please enter a valid URL.', life: 3000 });
return;
}
if (formData.value.adFormat === 'mid-roll' && !formData.value.duration) {
toast.add({ severity: 'error', summary: 'Duration Required', detail: 'Mid-roll ads require a duration/interval.', life: 3000 });
return;
}
if (editingTemplate.value) {
const index = templates.value.findIndex(t => t.id === editingTemplate.value!.id);
if (index !== -1) {
templates.value[index] = { ...templates.value[index], ...formData.value };
}
toast.add({ severity: 'success', summary: 'Template Updated', detail: 'VAST template has been updated.', life: 3000 });
} else {
templates.value.push({
id: Math.random().toString(36).substring(2, 9),
...formData.value,
enabled: true,
createdAt: new Date().toISOString().split('T')[0],
});
toast.add({ severity: 'success', summary: 'Template Created', detail: 'VAST template has been created.', life: 3000 });
}
showAddDialog.value = false;
resetForm();
};
const handleToggle = (template: VastTemplate) => {
template.enabled = !template.enabled;
toast.add({
severity: 'info',
summary: template.enabled ? 'Template Enabled' : 'Template Disabled',
detail: `${template.name} has been ${template.enabled ? 'enabled' : 'disabled'}.`,
life: 2000
});
};
const handleDelete = (template: VastTemplate) => {
confirm.require({
message: `Are you sure you want to delete "${template.name}"?`,
header: 'Delete Template',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
accept: () => {
const index = templates.value.findIndex(t => t.id === template.id);
if (index !== -1) templates.value.splice(index, 1);
toast.add({ severity: 'info', summary: 'Template Deleted', detail: 'VAST template has been removed.', life: 3000 });
}
});
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: 'URL copied to clipboard.', life: 2000 });
};
const getAdFormatLabel = (format: string) => {
const labels: Record<string, string> = {
'pre-roll': 'Pre-roll',
'mid-roll': 'Mid-roll',
'post-roll': 'Post-roll',
};
return labels[format] || format;
};
const 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>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Ads & VAST</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Create and manage VAST ad templates for your videos.
</p>
</div>
<AppButton size="sm" @click="openAddDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
Create Template
</AppButton>
</div>
<!-- Info Banner -->
<div class="px-6 py-3 bg-info/5 border-b border-info/20">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-info mt-0.5" />
<div class="text-xs text-foreground/70">
VAST (Video Ad Serving Template) is an XML schema for serving ad tags to video players.
</div>
</div>
</div>
<!-- Templates Table -->
<div class="border-b border-border">
<table class="w-full">
<thead class="bg-muted/30">
<tr>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Template</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Format</th>
<th class="text-left text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">VAST URL</th>
<th class="text-center text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Status</th>
<th class="text-right text-xs font-medium text-foreground/50 uppercase tracking-wider px-6 py-3">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-border">
<tr
v-for="template in templates"
:key="template.id"
class="hover:bg-muted/30 transition-all"
>
<td class="px-6 py-3">
<div>
<span class="text-sm font-medium text-foreground">{{ template.name }}</span>
<p class="text-xs text-foreground/50 mt-0.5">Created {{ template.createdAt }}</p>
</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-[200px]">
<code class="text-xs text-foreground/60 truncate">{{ template.vastUrl }}</code>
<AppButton variant="ghost" size="sm" @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"
@update:model-value="handleToggle(template)"
/>
</td>
<td class="px-6 py-3 text-right">
<div class="flex items-center justify-end gap-1">
<AppButton variant="ghost" size="sm" @click="openEditDialog(template)">
<template #icon>
<PencilIcon class="w-4 h-4" />
</template>
</AppButton>
<AppButton variant="ghost" size="sm" @click="handleDelete(template)">
<template #icon>
<TrashIcon class="w-4 h-4 text-danger" />
</template>
</AppButton>
</div>
</td>
</tr>
<tr v-if="templates.length === 0">
<td colspan="5" class="px-6 py-12 text-center">
<LinkIcon class="w-10 h-10 text-foreground/30 mb-3 block mx-auto" />
<p class="text-sm text-foreground/60 mb-1">No VAST templates yet</p>
<p class="text-xs text-foreground/40">Create a template to start monetizing your videos</p>
</td>
</tr>
</tbody>
</table>
</div>
<!-- Add/Edit Dialog -->
<AppDialog
:visible="showAddDialog"
@update:visible="showAddDialog = $event"
:title="editingTemplate ? 'Edit Template' : 'Create VAST Template'"
maxWidthClass="max-w-lg"
>
<div class="space-y-4">
<div class="grid gap-2">
<label for="name" class="text-sm font-medium text-foreground">Template Name</label>
<AppInput
id="name"
v-model="formData.name"
placeholder="e.g., Main Pre-roll Ad"
/>
</div>
<div class="grid gap-2">
<label for="vastUrl" class="text-sm font-medium text-foreground">VAST Tag URL</label>
<AppInput
id="vastUrl"
v-model="formData.vastUrl"
placeholder="https://ads.example.com/vast/tag.xml"
/>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-foreground">Ad Format</label>
<div class="grid grid-cols-3 gap-2">
<button
v-for="format in ['pre-roll', 'mid-roll', 'post-roll']"
:key="format"
@click="formData.adFormat = format as any"
:class="[
'px-3 py-2 border rounded-md text-sm font-medium capitalize transition-all',
formData.adFormat === format
? 'border-primary bg-primary/5 text-primary'
: 'border-border text-foreground/60 hover:border-primary/50'
]">
{{ format }}
</button>
</div>
</div>
<div v-if="formData.adFormat === 'mid-roll'" class="grid gap-2">
<label for="duration" class="text-sm font-medium text-foreground">Ad Interval (seconds)</label>
<AppInput
id="duration"
v-model.number="formData.duration"
type="number"
placeholder="30"
:min="10"
:max="600"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton variant="secondary" size="sm" @click="showAddDialog = false">
Cancel
</AppButton>
<AppButton size="sm" @click="handleSave">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
{{ editingTemplate ? 'Update' : 'Create' }}
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -1,474 +0,0 @@
<script setup lang="ts">
import { client, type ModelPlan } from '@/api/client';
import ActivityIcon from '@/components/icons/ActivityIcon.vue';
import CoinsIcon from '@/components/icons/CoinsIcon.vue';
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
import DownloadIcon from '@/components/icons/DownloadIcon.vue';
import UploadIcon from '@/components/icons/UploadIcon.vue';
import { useAuthStore } from '@/stores/auth';
import { useQuery } from '@pinia/colada';
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AppInput from '@/components/app/AppInput.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import PlusIcon from '@/components/icons/PlusIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { computed, ref } from 'vue';
const toast = useAppToast();
const auth = useAuthStore();
const { data, isPending, isLoading } = useQuery({
key: () => ['payments-and-plans'],
query: () => client.plans.plansList(),
});
const subscribing = ref<string | null>(null);
// Top-up state
const topupDialogVisible = ref(false);
const topupAmount = ref<number | null>(0);
const topupLoading = ref(false);
const topupPresets = [10, 20, 50, 100];
// Mock Payment History Data
const paymentHistory = ref([
{ id: 'inv_001', date: 'Oct 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-001' },
{ id: 'inv_002', date: 'Nov 24, 2025', amount: 9.99, plan: 'Basic Plan', status: 'success', invoiceId: 'INV-2025-002' },
{ id: 'inv_003', date: 'Dec 24, 2025', amount: 19.99, plan: 'Pro Plan', status: 'failed', invoiceId: 'INV-2025-003' },
{ id: 'inv_004', date: 'Jan 24, 2026', amount: 19.99, plan: 'Pro Plan', status: 'pending', invoiceId: 'INV-2026-001' },
]);
// Computed Usage (from user data)
const storageUsed = computed(() => auth.user?.storage_used || 0);
const storageLimit = computed(() => 10737418240);
const uploadsUsed = ref(12);
const uploadsLimit = ref(50);
// Wallet balance (from user data or mock)
const walletBalance = computed(() => auth.user?.wallet_balance || 0);
const currentPlanId = computed(() => {
if (auth.user?.plan_id) return auth.user.plan_id;
if (Array.isArray(data?.value?.data?.data.plans) && data?.value?.data?.data.plans.length > 0) return data.value.data.data.plans[0].id;
return undefined;
});
const currentPlan = computed(() => {
if (!Array.isArray(data?.value?.data?.data.plans)) return undefined;
return data.value.data.data.plans.find(p => p.id === currentPlanId.value);
});
// Percentages
const storagePercentage = computed(() =>
Math.min(Math.round((storageUsed.value / storageLimit.value) * 100), 100)
);
const uploadsPercentage = computed(() =>
Math.min(Math.round((uploadsUsed.value / uploadsLimit.value) * 100), 100)
);
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));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '0 mins';
return `${Math.floor(seconds / 60)} mins`;
};
const getStatusStyles = (status: string) => {
switch (status) {
case 'success':
return 'bg-success/10 text-success';
case 'failed':
return 'bg-danger/10 text-danger';
case 'pending':
return 'bg-warning/10 text-warning';
default:
return 'bg-info/10 text-info';
}
};
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
const subscribe = async (plan: ModelPlan) => {
if (!plan.id) return;
subscribing.value = plan.id;
try {
await client.payments.paymentsCreate({
amount: plan.price || 0,
plan_id: plan.id
});
toast.add({
severity: 'success',
summary: 'Subscription Successful',
detail: `Successfully subscribed to ${plan.name}`,
life: 3000
});
paymentHistory.value.unshift({
id: `inv_${Date.now()}`,
date: new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }),
amount: plan.price || 0,
plan: plan.name || 'Unknown',
status: 'success',
invoiceId: `INV-${new Date().getFullYear()}-${Math.floor(Math.random() * 1000)}`
});
} catch (err: any) {
console.error(err);
toast.add({
severity: 'error',
summary: 'Subscription Failed',
detail: err.message || 'Failed to subscribe',
life: 5000
});
} finally {
subscribing.value = null;
}
};
const handleTopup = async (amount: number) => {
topupLoading.value = true;
try {
// TODO: Add API endpoint for top-up
await new Promise(resolve => setTimeout(resolve, 1500));
toast.add({
severity: 'success',
summary: 'Top-up Successful',
detail: `$${amount} has been added to your wallet.`,
life: 3000
});
topupDialogVisible.value = false;
topupAmount.value = null;
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Top-up Failed',
detail: e.message || 'Failed to process top-up.',
life: 5000
});
} finally {
topupLoading.value = false;
}
};
const handleDownloadInvoice = (item: typeof paymentHistory.value[number]) => {
toast.add({
severity: 'info',
summary: 'Downloading',
detail: `Downloading invoice #${item.invoiceId}...`,
life: 2000
});
setTimeout(() => {
toast.add({
severity: 'success',
summary: 'Downloaded',
detail: `Invoice #${item.invoiceId} downloaded successfully`,
life: 3000
});
}, 1500);
};
const openTopupDialog = () => {
topupAmount.value = null;
topupDialogVisible.value = true;
};
const selectPreset = (amount: number) => {
topupAmount.value = amount;
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Billing & Plans</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your subscription, wallet, and billing information.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Wallet Balance -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CoinsIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Wallet Balance</p>
<p class="text-xs text-foreground/60 mt-0.5">
Current balance: ${{ walletBalance.toFixed(2) }}
</p>
</div>
</div>
<AppButton size="sm" @click="openTopupDialog">
<template #icon>
<PlusIcon class="w-4 h-4" />
</template>
Top Up
</AppButton>
</div>
<!-- Available Plans -->
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<CreditCardIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Available Plans</p>
<p class="text-xs text-foreground/60 mt-0.5">
Choose the plan that best fits your needs
</p>
</div>
</div>
<!-- Loading State -->
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div v-for="i in 3" :key="i">
<div class="h-[200px] rounded-lg bg-muted/50 animate-pulse"></div>
</div>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div
v-for="plan in data?.data?.data.plans || []"
:key="plan.id"
class="border border-border rounded-lg p-4 hover:bg-muted/30 transition-all"
>
<div class="mb-3">
<h3 class="text-lg font-semibold text-foreground">{{ plan.name }}</h3>
<p class="text-sm text-foreground/60 mt-1 min-h-[2.5rem]">{{ plan.description }}</p>
</div>
<div class="mb-4">
<span class="text-2xl font-bold text-foreground">${{ plan.price }}</span>
<span class="text-foreground/60 text-sm">/{{ plan.cycle }}</span>
</div>
<ul class="space-y-2 mb-4 text-sm">
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatBytes(plan.storage_limit || 0) }} Storage
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ formatDuration(plan.duration_limit) }} Max Duration
</li>
<li class="flex items-center gap-2 text-foreground/70">
<CheckIcon class="w-4 h-4 text-success shrink-0" />
{{ plan.upload_limit }} Uploads / day
</li>
</ul>
<button
:disabled="!!subscribing || plan.id === currentPlanId"
:class="[
'w-full py-2 px-4 rounded-md text-sm font-medium transition-all',
plan.id === currentPlanId
? 'bg-muted/50 text-foreground/60 cursor-not-allowed'
: subscribing === plan.id
? 'bg-muted/50 text-foreground/60 cursor-wait'
: 'bg-primary text-primary-foreground hover:bg-primary/90'
]"
@click="subscribe(plan)"
>
{{ plan.id === currentPlanId ? 'Current Plan' : (subscribing === plan.id ? 'Processing...' : 'Upgrade') }}
</button>
</div>
</div>
</div>
<!-- Storage Usage -->
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-accent/10 flex items-center justify-center shrink-0">
<ActivityIcon class="w-5 h-5 text-accent" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Storage</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used
</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-primary h-full rounded-full transition-all duration-300"
:style="{ width: `${storagePercentage}%` }"
></div>
</div>
</div>
<!-- Uploads Usage -->
<div class="px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4 mb-3">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<UploadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Monthly Uploads</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ uploadsUsed }} of {{ uploadsLimit }} uploads
</p>
</div>
</div>
<div class="w-full bg-muted/50 rounded-full overflow-hidden" style="height: 6px">
<div
class="bg-info h-full rounded-full transition-all duration-300"
:style="{ width: `${uploadsPercentage}%` }"
></div>
</div>
</div>
<!-- Payment History -->
<div class="px-6 py-4">
<div class="flex items-center gap-4 mb-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<DownloadIcon class="w-5 h-5 text-info" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Payment History</p>
<p class="text-xs text-foreground/60 mt-0.5">
Your past payments and invoices
</p>
</div>
</div>
<div class="border border-border rounded-lg overflow-hidden">
<!-- Table Header -->
<div class="grid grid-cols-12 gap-4 px-4 py-3 text-xs font-medium text-foreground/60 uppercase tracking-wider bg-muted/30">
<div class="col-span-3">Date</div>
<div class="col-span-2">Amount</div>
<div class="col-span-3">Plan</div>
<div class="col-span-2">Status</div>
<div class="col-span-2 text-right">Invoice</div>
</div>
<!-- Empty State -->
<div v-if="paymentHistory.length === 0" class="text-center py-12 text-foreground/60">
<div class="w-16 h-16 rounded-full bg-muted/50 flex items-center justify-center mx-auto mb-4">
<DownloadIcon class="w-8 h-8 text-foreground/40" />
</div>
<p>No payment history found.</p>
</div>
<!-- Table Rows -->
<div
v-for="item in paymentHistory"
:key="item.id"
class="grid grid-cols-12 gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-all border-t border-border"
>
<div class="col-span-3">
<p class="text-sm font-medium text-foreground">{{ item.date }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-foreground">${{ item.amount }}</p>
</div>
<div class="col-span-3">
<p class="text-sm text-foreground">{{ item.plan }}</p>
</div>
<div class="col-span-2">
<span
:class="`inline-flex items-center px-2.5 py-1 rounded-md text-xs font-medium ${getStatusStyles(item.status)}`"
>
{{ capitalize(item.status) }}
</span>
</div>
<div class="col-span-2 flex justify-end">
<button
class="flex items-center gap-2 px-3 py-1.5 text-sm text-foreground/70 hover:text-foreground hover:bg-muted/50 rounded-md transition-all"
@click="handleDownloadInvoice(item)"
>
<DownloadIcon class="w-4 h-4" />
<span>Download</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Top-up Dialog -->
<AppDialog
:visible="topupDialogVisible"
@update:visible="topupDialogVisible = $event"
title="Top Up Wallet"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Select an amount or enter a custom amount to add to your wallet.
</p>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3">
<button
v-for="preset in topupPresets"
:key="preset"
:class="[
'py-2 px-3 rounded-md text-sm font-medium transition-all',
topupAmount === preset
? 'bg-primary text-primary-foreground'
: 'bg-muted/50 text-foreground hover:bg-muted'
]"
@click="selectPreset(preset)"
>
${{ preset }}
</button>
</div>
<!-- Custom Amount -->
<div class="space-y-2">
<label class="text-sm font-medium text-foreground">Custom Amount</label>
<div class="flex items-center gap-2">
<span class="text-lg font-semibold text-foreground">$</span>
<AppInput
v-model.number="topupAmount"
type="number"
placeholder="Enter amount"
inputClass="flex-1"
min="1"
step="1"
/>
</div>
</div>
<!-- Info -->
<div class="bg-muted/30 rounded-md p-3 text-xs text-foreground/60">
<p>Minimum top-up amount is $1. Funds will be added to your wallet immediately after payment.</p>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton
variant="secondary"
size="sm"
:disabled="topupLoading"
@click="topupDialogVisible = false"
>
Cancel
</AppButton>
<AppButton
size="sm"
:loading="topupLoading"
:disabled="!topupAmount || topupAmount < 1 || topupLoading"
@click="handleTopup(topupAmount || 0)"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Proceed to Payment
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

@@ -1,121 +0,0 @@
<script setup lang="ts">
import AlertTriangleIcon from '@/components/icons/AlertTriangle.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import TrashIcon from '@/components/icons/TrashIcon.vue';
import SlidersIcon from '@/components/icons/SlidersIcon.vue';
import AppButton from '@/components/app/AppButton.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
const toast = useAppToast();
const confirm = useAppConfirm();
const handleDeleteAccount = () => {
confirm.require({
message: 'Are you sure you want to delete your account? This action cannot be undone.',
header: 'Delete Account',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
accept: () => {
toast.add({
severity: 'info',
summary: 'Account deletion requested',
detail: 'Your account deletion request has been submitted.',
life: 5000
});
}
});
};
const handleClearData = () => {
confirm.require({
message: 'Are you sure you want to clear all your data? This action cannot be undone.',
header: 'Clear All Data',
acceptLabel: 'Clear',
rejectLabel: 'Cancel',
accept: () => {
toast.add({
severity: 'info',
summary: 'Data cleared',
detail: 'All your data has been permanently deleted.',
life: 5000
});
}
});
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-danger">Danger Zone</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Irreversible and destructive actions. Be careful!
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Delete Account -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<AlertTriangleIcon class="w-5 h-5 text-danger" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Delete Account</p>
<p class="text-xs text-foreground/60 mt-0.5">
Permanently delete your account and all associated data.
</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleDeleteAccount">
<template #icon>
<TrashIcon class="w-4 h-4" />
</template>
Delete Account
</AppButton>
</div>
<!-- Clear All Data -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-danger/5 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-danger/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-danger" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Clear All Data</p>
<p class="text-xs text-foreground/60 mt-0.5">
Remove all your videos, playlists, and activity history.
</p>
</div>
</div>
<AppButton variant="danger" size="sm" @click="handleClearData">
<template #icon>
<SlidersIcon class="w-4 h-4" />
</template>
Clear Data
</AppButton>
</div>
</div>
<!-- Warning Banner -->
<div class="mx-6 my-4 border border-warning/30 bg-warning/5 rounded-md p-4">
<div class="flex items-start gap-2">
<InfoIcon class="w-4 h-4 text-warning mt-0.5" />
<div class="text-xs text-foreground/70">
<p class="font-medium text-foreground mb-1">Warning</p>
<p>
These actions are permanent and cannot be undone.
Make sure you have backed up any important data before proceeding.
</p>
</div>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -1,126 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import AppButton from '@/components/app/AppButton.vue';
import AppSwitch from '@/components/app/AppSwitch.vue';
import CheckIcon from '@/components/icons/CheckIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import MailIcon from '@/components/icons/MailIcon.vue';
import BellIcon from '@/components/icons/BellIcon.vue';
import SendIcon from '@/components/icons/SendIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
const toast = useAppToast();
const notificationSettings = ref({
email: true,
push: true,
marketing: false,
telegram: false,
});
const saving = ref(false);
const notificationTypes = [
{
key: 'email' as const,
title: 'Email Notifications',
description: 'Receive updates and alerts via email',
icon: MailIcon,
bgColor: 'bg-primary/10',
iconColor: 'text-primary',
},
{
key: 'push' as const,
title: 'Push Notifications',
description: 'Get instant alerts in your browser',
icon: BellIcon,
bgColor: 'bg-accent/10',
iconColor: 'text-accent',
},
{
key: 'marketing' as const,
title: 'Marketing Emails',
description: 'Receive promotions and product updates',
icon: SendIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
{
key: 'telegram' as const,
title: 'Telegram Notifications',
description: 'Receive updates via Telegram',
icon: TelegramIcon,
bgColor: 'bg-info/10',
iconColor: 'text-info',
},
];
const handleSave = async () => {
saving.value = true;
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
toast.add({
severity: 'success',
summary: 'Settings Saved',
detail: 'Your notification settings have been saved.',
life: 3000
});
} catch (e: any) {
toast.add({
severity: 'error',
summary: 'Save Failed',
detail: e.message || 'Failed to save settings.',
life: 5000
});
} finally {
saving.value = false;
}
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-foreground">Notifications</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Choose how you want to receive notifications and updates.
</p>
</div>
<AppButton
size="sm"
:loading="saving"
@click="handleSave"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Save Changes
</AppButton>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<div
v-for="type in notificationTypes"
:key="type.key"
class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all"
>
<div class="flex items-center gap-4">
<div
:class="`:uno: w-10 h-10 rounded-md flex items-center justify-center shrink-0 ${type.bgColor}`"
>
<component :is="type.icon" :class="`${type.iconColor} w-5 h-5`" />
</div>
<div>
<p class="text-sm font-medium text-foreground">{{ type.title }}</p>
<p class="text-xs text-foreground/60 mt-0.5">{{ type.description }}</p>
</div>
</div>
<AppSwitch v-model="notificationSettings[type.key]" />
</div>
</div>
</div>
</template>

View File

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

View File

@@ -1,436 +0,0 @@
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
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 LockIcon from '@/components/icons/LockIcon.vue';
import TelegramIcon from '@/components/icons/TelegramIcon.vue';
import { useAppToast } from '@/composables/useAppToast';
import { ref } from 'vue';
const auth = useAuthStore();
const toast = useAppToast();
// 2FA state
const twoFactorEnabled = ref(false);
const twoFactorDialogVisible = ref(false);
const twoFactorCode = ref('');
const twoFactorSecret = ref('JBSWY3DPEHPK3PXP');
// Connected accounts state
const emailConnected = ref(true);
const telegramConnected = ref(false);
const telegramUsername = ref('');
// Change password state
const changePasswordDialogVisible = ref(false);
const currentPassword = ref('');
const newPassword = ref('');
const confirmPassword = ref('');
const changePasswordLoading = ref(false);
const changePasswordError = ref('');
// Change password handler
const openChangePassword = () => {
changePasswordDialogVisible.value = true;
changePasswordError.value = '';
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
};
const changePassword = async () => {
changePasswordError.value = '';
if (newPassword.value !== confirmPassword.value) {
changePasswordError.value = 'Passwords do not match';
return;
}
if (newPassword.value.length < 6) {
changePasswordError.value = 'Password must be at least 6 characters';
return;
}
changePasswordLoading.value = true;
try {
await auth.changePassword(currentPassword.value, newPassword.value);
changePasswordDialogVisible.value = false;
currentPassword.value = '';
newPassword.value = '';
confirmPassword.value = '';
toast.add({
severity: 'success',
summary: 'Password Changed',
detail: 'Your password has been changed successfully.',
life: 3000
});
} catch (e: any) {
changePasswordError.value = e.message || 'Failed to change password';
} finally {
changePasswordLoading.value = false;
}
};
// Toggle 2FA
const handleToggle2FA = async () => {
if (!twoFactorEnabled.value) {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorDialogVisible.value = true;
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Failed to enable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = false;
}
} else {
try {
await new Promise(resolve => setTimeout(resolve, 500));
toast.add({
severity: 'success',
summary: '2FA Disabled',
detail: 'Two-factor authentication has been disabled.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disable 2FA Failed',
detail: 'Failed to disable two-factor authentication.',
life: 5000
});
twoFactorEnabled.value = true;
}
}
};
// Confirm 2FA setup
const confirmTwoFactor = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
twoFactorEnabled.value = true;
toast.add({
severity: 'success',
summary: '2FA Enabled',
detail: 'Two-factor authentication has been enabled successfully.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Enable 2FA Failed',
detail: 'Invalid verification code. Please try again.',
life: 5000
});
}
};
// Connect Telegram
const connectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 1000));
telegramConnected.value = true;
telegramUsername.value = '@telegram_user';
toast.add({
severity: 'success',
summary: 'Telegram Connected',
detail: `Connected to ${telegramUsername.value}`,
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Connection Failed',
detail: 'Failed to connect Telegram account.',
life: 5000
});
}
};
// Disconnect Telegram
const disconnectTelegram = async () => {
try {
await new Promise(resolve => setTimeout(resolve, 500));
telegramConnected.value = false;
telegramUsername.value = '';
toast.add({
severity: 'info',
summary: 'Telegram Disconnected',
detail: 'Your Telegram account has been disconnected.',
life: 3000
});
} catch (e) {
toast.add({
severity: 'error',
summary: 'Disconnect Failed',
detail: 'Failed to disconnect Telegram account.',
life: 5000
});
}
};
</script>
<template>
<div class="bg-surface border border-border rounded-lg">
<!-- Header -->
<div class="px-6 py-4 border-b border-border">
<h2 class="text-base font-semibold text-foreground">Security & Connected Accounts</h2>
<p class="text-sm text-foreground/60 mt-0.5">
Manage your security settings and connected services.
</p>
</div>
<!-- Content -->
<div class="divide-y divide-border">
<!-- Account Status -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-success/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-success" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/>
<polyline points="22 4 12 14.01 9 11.01"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Account Status</p>
<p class="text-xs text-foreground/60 mt-0.5">Your account is in good standing</p>
</div>
</div>
<span class="text-xs font-medium text-success bg-success/10 px-2 py-1 rounded">Active</span>
</div>
<!-- Two-Factor Authentication -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<LockIcon class="w-5 h-5 text-primary" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Two-Factor Authentication</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ twoFactorEnabled ? '2FA is enabled' : 'Add an extra layer of security' }}
</p>
</div>
</div>
<AppSwitch v-model="twoFactorEnabled" @change="handleToggle2FA" />
</div>
<!-- Change Password -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-primary/10 flex items-center justify-center shrink-0">
<svg aria-hidden="true" class="fill-primary" height="24" viewBox="0 0 24 24" version="1.1" width="24" data-view-component="true">
<path d="M22 9.75v5.5A1.75 1.75 0 0 1 20.25 17H3.75A1.75 1.75 0 0 1 2 15.25v-5.5C2 8.784 2.784 8 3.75 8h16.5c.966 0 1.75.784 1.75 1.75Zm-8.75 2.75a1.25 1.25 0 1 0-2.5 0 1.25 1.25 0 0 0 2.5 0Zm-6.5 1.25a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Zm10.5 0a1.25 1.25 0 1 0 0-2.5 1.25 1.25 0 0 0 0 2.5Z"></path>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Change Password</p>
<p class="text-xs text-foreground/60 mt-0.5">Update your account password</p>
</div>
</div>
<AppButton size="sm" @click="openChangePassword">
Change Password
</AppButton>
</div>
<!-- Email Connection -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-info/10 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-info" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2"/>
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-foreground">Email</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ emailConnected ? 'Connected' : 'Not connected' }}
</p>
</div>
</div>
<span class="text-xs font-medium px-2 py-1 rounded" :class="emailConnected ? 'text-success bg-success/10' : 'text-muted bg-muted/20'">
{{ emailConnected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<!-- Telegram Connection -->
<div class="flex items-center justify-between px-6 py-4 hover:bg-muted/30 transition-all">
<div class="flex items-center gap-4">
<div class="w-10 h-10 rounded-md bg-[#0088cc]/10 flex items-center justify-center shrink-0">
<TelegramIcon class="w-5 h-5 text-[#0088cc]" />
</div>
<div>
<p class="text-sm font-medium text-foreground">Telegram</p>
<p class="text-xs text-foreground/60 mt-0.5">
{{ telegramConnected ? (telegramUsername || 'Connected') : 'Get notified via Telegram' }}
</p>
</div>
</div>
<AppButton
v-if="telegramConnected"
variant="danger"
size="sm"
@click="disconnectTelegram"
>
Disconnect
</AppButton>
<AppButton
v-else
size="sm"
@click="connectTelegram"
>
Connect
</AppButton>
</div>
</div>
<!-- 2FA Setup Dialog -->
<AppDialog
:visible="twoFactorDialogVisible"
@update:visible="twoFactorDialogVisible = $event"
title="Enable Two-Factor Authentication"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Scan the QR code below with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<!-- QR Code Placeholder -->
<div class="flex justify-center py-4">
<div class="w-48 h-48 bg-muted rounded-lg flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="w-16 h-16 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
</div>
</div>
<!-- Secret Key -->
<div class="bg-muted/30 rounded-md p-3">
<p class="text-xs text-foreground/60 mb-1">Secret Key:</p>
<code class="text-sm font-mono text-primary">{{ twoFactorSecret }}</code>
</div>
<!-- Verification Code Input -->
<div class="grid gap-2">
<label for="twoFactorCode" class="text-sm font-medium text-foreground">Verification Code</label>
<AppInput
id="twoFactorCode"
v-model="twoFactorCode"
placeholder="Enter 6-digit code"
:maxlength="6"
/>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton variant="secondary" size="sm" @click="twoFactorDialogVisible = false">
Cancel
</AppButton>
<AppButton size="sm" @click="confirmTwoFactor">
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Verify & Enable
</AppButton>
</div>
</template>
</AppDialog>
<!-- Change Password Dialog -->
<AppDialog
:visible="changePasswordDialogVisible"
@update:visible="changePasswordDialogVisible = $event"
title="Change Password"
maxWidthClass="max-w-md"
>
<div class="space-y-4">
<p class="text-sm text-foreground/70">
Enter your current password and choose a new password.
</p>
<!-- Error Message -->
<div v-if="changePasswordError" class="bg-danger/10 border border-danger text-danger text-sm rounded-md p-3">
{{ changePasswordError }}
</div>
<!-- Current Password -->
<div class="grid gap-2">
<label for="currentPassword" class="text-sm font-medium text-foreground">Current Password</label>
<AppInput
id="currentPassword"
v-model="currentPassword"
type="password"
placeholder="Enter current password"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- New Password -->
<div class="grid gap-2">
<label for="newPassword" class="text-sm font-medium text-foreground">New Password</label>
<AppInput
id="newPassword"
v-model="newPassword"
type="password"
placeholder="Enter new password"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
<!-- Confirm Password -->
<div class="grid gap-2">
<label for="confirmPassword" class="text-sm font-medium text-foreground">Confirm New Password</label>
<AppInput
id="confirmPassword"
v-model="confirmPassword"
type="password"
placeholder="Confirm new password"
>
<template #prefix>
<LockIcon class="w-5 h-5" />
</template>
</AppInput>
</div>
</div>
<template #footer>
<div class="flex justify-end gap-3">
<AppButton
variant="secondary"
size="sm"
:disabled="changePasswordLoading"
@click="changePasswordDialogVisible = false"
>
Cancel
</AppButton>
<AppButton
size="sm"
:loading="changePasswordLoading"
@click="changePassword"
>
<template #icon>
<CheckIcon class="w-4 h-4" />
</template>
Change Password
</AppButton>
</div>
</template>
</AppDialog>
</div>
</template>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import { client, type ModelVideo } from '@/api/client';
import { fetchMockVideoById } from '@/mocks/videos';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
const props = defineProps<{ const props = defineProps<{
videoId: string; videoId: string;
@@ -16,17 +16,24 @@ const toast = useAppToast();
const video = ref<ModelVideo | null>(null); const video = ref<ModelVideo | null>(null);
const loading = ref(true); const loading = ref(true);
const copiedField = ref<string | null>(null); const copiedField = ref<string | null>(null);
const { t } = useTranslation();
const fetchVideo = async () => { const fetchVideo = async () => {
loading.value = true; loading.value = true;
try { try {
const videoData = await fetchMockVideoById(props.videoId); const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
const videoData = (response.data as any)?.data?.video || (response.data as any)?.data;
if (videoData) { if (videoData) {
video.value = videoData; video.value = videoData;
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch video:', error); console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 }); toast.add({
severity: 'error',
summary: t('video.copyModal.toastErrorSummary'),
detail: t('video.copyModal.toastErrorDetail'),
life: 3000
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -37,23 +44,25 @@ const baseUrl = computed(() => typeof window !== 'undefined' ? window.location.o
const shareLinks = computed(() => { const shareLinks = computed(() => {
if (!video.value) return []; if (!video.value) return [];
const v = video.value; const v = video.value;
const playbackPath = v.url || `/play/index/${v.id}`;
const playbackUrl = playbackPath.startsWith('http') ? playbackPath : `${baseUrl.value}${playbackPath}`;
return [ return [
{ {
key: 'embed', key: 'embed',
label: 'Embed player (recommended)', label: t('video.copyModal.embedPlayer'),
value: `${baseUrl.value}/play/index/${v.id}`, value: playbackUrl,
}, },
{ {
key: 'thumbnail', key: 'thumbnail',
label: 'Thumbnail URL', label: t('video.copyModal.thumbnail'),
value: v.thumbnail || '', value: v.thumbnail || '',
}, },
{ {
key: 'hls', key: 'hls',
label: 'HLS link (VIP only)', label: t('video.copyModal.hls'),
value: v.hls_path ? `${baseUrl.value}/hls/getlink/${v.id}/${v.hls_token}/${v.hls_path}` : '', value: playbackUrl,
placeholder: 'HLS link available for VIP with whitelisted domain', placeholder: t('video.copyModal.hlsPlaceholder'),
hint: 'This link redirects to a signed HLS URL and only works on whitelisted domains.', hint: t('video.copyModal.hlsHint'),
}, },
]; ];
}); });
@@ -70,7 +79,12 @@ const copyToClipboard = async (text: string, key: string) => {
document.body.removeChild(textArea); document.body.removeChild(textArea);
} }
copiedField.value = key; copiedField.value = key;
toast.add({ severity: 'success', summary: 'Copied', detail: 'Copied to clipboard', life: 2000 }); toast.add({
severity: 'success',
summary: t('video.copyModal.toastCopiedSummary'),
detail: t('video.copyModal.toastCopiedDetail'),
life: 2000
});
setTimeout(() => { setTimeout(() => {
copiedField.value = null; copiedField.value = null;
}, 2000); }, 2000);
@@ -87,7 +101,7 @@ watch(() => props.videoId, (newId) => {
<template> <template>
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl" <AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:title="loading ? '' : 'Get sharing address'"> :title="loading ? '' : t('video.copyModal.title')">
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-5"> <div v-if="loading" class="flex flex-col gap-5">
@@ -111,13 +125,13 @@ watch(() => props.videoId, (newId) => {
<div v-else class="flex flex-col gap-5"> <div v-else class="flex flex-col gap-5">
<!-- Player addresses --> <!-- Player addresses -->
<div> <div>
<p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">Player address</p> <p class="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-3">{{ t('video.copyModal.playerAddress') }}</p>
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5"> <div v-for="link in shareLinks" :key="link.key" class="flex flex-col gap-1.5">
<p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p> <p class="text-sm font-medium text-muted-foreground">{{ link.label }}</p>
<div class="flex gap-2"> <div class="flex gap-2">
<AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly <AppInput :model-value="link.value || ''" :placeholder="link.placeholder" readonly
input-class="!font-mono !text-xs" @click="($event.target as HTMLInputElement)?.select()" /> input-class="!font-mono !text-xs" wrapperClass="w-full" @click="($event.target as HTMLInputElement)?.select()" />
<AppButton variant="secondary" :disabled="!link.value || copiedField === link.key" <AppButton variant="secondary" :disabled="!link.value || copiedField === link.key"
@click="copyToClipboard(link.value, link.key)" class="shrink-0"> @click="copyToClipboard(link.value, link.key)" class="shrink-0">
<!-- Copy icon --> <!-- Copy icon -->
@@ -143,18 +157,15 @@ watch(() => props.videoId, (newId) => {
<!-- Notices --> <!-- Notices -->
<div class="flex flex-col gap-2 text-sm"> <div class="flex flex-col gap-2 text-sm">
<div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3"> <div class="rounded-xl border border-red-500/30 bg-red-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm"> <div class="flex-1 text-sm">
<p class="font-medium text-red-900 dark:text-red-100 mb-1">Warning</p> <p class="font-medium text-red-900 dark:text-red-100 mb-1">{{ t('video.copyModal.warningTitle') }}</p>
<p class="text-red-800 dark:text-red-200">Make sure shared files comply with <strong>local laws</strong> and confirm you understand the responsibilities involved when distributing content.</p> <p class="text-red-800 dark:text-red-200">{{ t('video.copyModal.warningDetail') }}</p>
</div> </div>
</div> </div>
<div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3"> <div class="rounded-xl border border-amber-500/30 bg-amber-500/10 p-3 flex items-start gap-3">
<div class="flex-1 text-sm"> <div class="flex-1 text-sm">
<p class="font-medium text-amber-900 dark:text-amber-100 mb-1">Reminder</p> <p class="font-medium text-amber-900 dark:text-amber-100 mb-1">{{ t('video.copyModal.reminderTitle') }}</p>
<p class="text-amber-800 dark:text-amber-200">The embed player can auto switch fallback nodes and works well on mobile. Raw HLS links <p class="text-amber-800 dark:text-amber-200">{{ t('video.copyModal.reminderDetail') }}</p>
rely on your own player and must be used only on whitelisted domains.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,190 +0,0 @@
<script setup lang="ts">
import type { ModelVideo } from '@/api/client';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import { deleteMockVideo, fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import { useAppConfirm } from '@/composables/useAppConfirm';
import { useAppToast } from '@/composables/useAppToast';
import { onMounted, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import VideoEditForm from './components/Detail/VideoEditForm.vue';
import VideoHeader from './components/Detail/VideoInfoHeader.vue';
import VideoPlayer from './components/Detail/VideoPlayer.vue';
import VideoSkeleton from './components/Detail/VideoSkeleton.vue';
const route = useRoute();
const router = useRouter();
const toast = useAppToast();
const confirm = useAppConfirm();
const videoId = route.params.id as string;
const video = ref<ModelVideo | null>(null);
const loading = ref(true);
const saving = ref(false);
const isEditing = ref(false);
const form = ref({
title: '',
description: '',
});
const fetchVideo = async () => {
loading.value = true;
try {
const videoData = await fetchMockVideoById(videoId);
if (videoData) {
video.value = videoData;
form.value.title = videoData.title || '';
form.value.description = videoData.description || '';
}
} catch (error) {
console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 });
router.push('/video');
} finally {
loading.value = false;
}
};
const handleReload = async () => {
toast.add({ severity: 'info', summary: 'Info', detail: 'Reloading video...', life: 2000 });
await fetchVideo();
};
const toggleEdit = () => {
isEditing.value = !isEditing.value;
if (!isEditing.value && video.value) {
form.value.title = video.value.title || '';
form.value.description = video.value.description || '';
}
};
const handleSave = async () => {
saving.value = true;
try {
await updateMockVideo(videoId, form.value);
if (video.value) {
video.value.title = form.value.title;
video.value.description = form.value.description;
}
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 });
isEditing.value = false;
} catch (error) {
console.error('Failed to save video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 });
} finally {
saving.value = false;
}
};
const handleDelete = () => {
confirm.require({
message: 'Are you sure you want to delete this video? This action cannot be undone.',
header: 'Confirm Delete',
acceptLabel: 'Delete',
rejectLabel: 'Cancel',
accept: async () => {
try {
await deleteMockVideo(videoId);
toast.add({ severity: 'success', summary: 'Success', detail: 'Video deleted successfully', life: 3000 });
router.push('/video');
} catch (error) {
console.error('Failed to delete video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to delete video', life: 3000 });
}
},
reject: () => { }
});
};
const copyToClipboard = async (text: string, label: string) => {
try {
await navigator.clipboard.writeText(text);
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
document.body.appendChild(textArea);
textArea.select();
document.execCommand('copy');
document.body.removeChild(textArea);
toast.add({ severity: 'success', summary: 'Copied', detail: `${label} copied to clipboard`, life: 2000 });
}
};
onMounted(() => {
fetchVideo();
});
const videoInfos = computed(() => {
if (!video) return [];
const embedUrl = video ? `${window.location.origin}/embed/${video.value?.id}` : '';
return [
{ label: 'Video ID', value: video.value?.id ?? '' },
{ label: 'Thumbnail URL', value: video.value?.thumbnail ?? '' },
{ label: 'Embed URL', value: embedUrl },
{ label: 'Iframe Code', value: embedUrl ? `<iframe src="${embedUrl}" title="${video.value?.title}" width="100%" height="400" frameborder="0" allowfullscreen></iframe>` : '' },
{ label: 'Share Link', value: video ? `${window.location.origin}/view/${video.value?.id}` : '' },
];
});
</script>
<template>
<div>
<PageHeader title="Video Detail" description="View and manage video details" :breadcrumbs="[
{ label: 'Dashboard', to: '/' },
{ label: 'Videos', to: '/video' },
{ label: video?.title || 'Loading...' }
]" />
<div class="mx-auto p-4 w-full">
<!-- Loading State -->
<VideoSkeleton v-if="loading" />
<!-- Content -->
<div v-else-if="video" class="flex flex-col lg:flex-row gap-4">
<VideoPlayer :video="video" class="lg:flex-1" />
<div class="bg-white rounded-lg border border-gray-200 max-w-full lg:max-w-md w-full flex flex-col">
<div class="px-6 py-4">
<VideoEditForm v-if="isEditing" v-model:title="form.title"
v-model:description="form.description" @save="handleSave" @toggle-edit="toggleEdit" :saving="saving" />
<div v-else>
<VideoHeader :video="video" @reload="handleReload"
@toggle-edit="toggleEdit" @delete="handleDelete" />
<div class="mb-4">
<h3 class="text-lg font-medium text-gray-900 mb-4">Video Details</h3>
<div class="flex flex-col gap-2">
<dl v-for="info in videoInfos" :key="info.label" class="space-y-2">
<div>
<dt class="text-sm font-medium text-gray-500">{{ info.label }}</dt>
<dd class="text-sm text-gray-900">
<div class="flex items-center space-x-2">
<input readonly
class="flex-1 px-2 py-1 text-xs border border-gray-300 rounded bg-gray-50 font-mono"
:value="info.value || '-'">
<button v-if="info.value"
@click="copyToClipboard(info.value, info.label)"
class="px-2 py-1 text-xs bg-gray-100 hover:bg-gray-200 border border-gray-300 rounded transition-colors text-gray-700"
title="Copy value">
<svg class="w-4 h-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round"
stroke-width="2"
d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z">
</path>
</svg>
</button>
</div>
</dd>
</div>
</dl>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ModelVideo } from '@/api/client'; import { client, type ManualAdTemplate, type ModelVideo } from '@/api/client';
import { fetchMockVideoById, updateMockVideo } from '@/mocks/videos';
import { useAppToast } from '@/composables/useAppToast'; import { useAppToast } from '@/composables/useAppToast';
import { ref, watch } from 'vue'; import { useAuthStore } from '@/stores/auth';
import { computed, ref, watch } from 'vue';
import { useTranslation } from 'i18next-vue';
const props = defineProps<{ const props = defineProps<{
videoId: string; videoId: string;
@@ -13,16 +14,35 @@ const emit = defineEmits<{
}>(); }>();
const toast = useAppToast(); const toast = useAppToast();
const auth = useAuthStore();
const video = ref<ModelVideo | null>(null); const video = ref<ModelVideo | null>(null);
const loading = ref(true); const loading = ref(true);
const saving = ref(false); const saving = ref(false);
const { t } = useTranslation();
type AdConfigPayload = {
ad_template_id: string;
template_name?: string;
vast_tag_url?: string;
ad_format?: string;
duration?: number;
};
const form = ref({ const form = ref({
title: '', title: '',
description: '', adTemplateId: '' as string,
}); });
const errors = ref<{ title?: string; description?: string }>({}); const currentAdConfig = ref<AdConfigPayload | null>(null);
const adTemplates = ref<ManualAdTemplate[]>([]);
const loadingTemplates = ref(false);
const errors = ref<{ title?: string }>({});
const isFreePlan = computed(() => !auth.user?.plan_id);
const activeTemplates = computed(() =>
adTemplates.value.filter(t => t.is_active),
);
const subtitleForm = ref({ const subtitleForm = ref({
file: null as File | null, file: null as File | null,
@@ -30,20 +50,43 @@ const subtitleForm = ref({
displayName: '', displayName: '',
}); });
const fetchAdTemplates = async () => {
loadingTemplates.value = true;
try {
const response = await client.adTemplates.adTemplatesList({ baseUrl: '/r' });
const items = ((response.data as any)?.data?.templates || []) as ManualAdTemplate[];
adTemplates.value = items;
} catch (error) {
console.error('Failed to fetch ad templates:', error);
} finally {
loadingTemplates.value = false;
}
};
const fetchVideo = async () => { const fetchVideo = async () => {
loading.value = true; loading.value = true;
try { try {
const videoData = await fetchMockVideoById(props.videoId); const response = await client.videos.videosDetail(props.videoId, { baseUrl: '/r' });
const data = (response.data as any)?.data;
const videoData = data?.video || data;
const adConfig = data?.ad_config as AdConfigPayload | undefined;
if (videoData) { if (videoData) {
video.value = videoData; video.value = videoData;
currentAdConfig.value = adConfig || null;
form.value = { form.value = {
title: videoData.title || '', title: videoData.title || '',
description: videoData.description || '', adTemplateId: adConfig?.ad_template_id || '',
}; };
} }
} catch (error) { } catch (error) {
console.error('Failed to fetch video:', error); console.error('Failed to fetch video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to load video details', life: 3000 }); toast.add({
severity: 'error',
summary: t('video.detailModal.toast.loadErrorSummary'),
detail: t('video.detailModal.toast.loadErrorDetail'),
life: 3000
});
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -52,7 +95,7 @@ const fetchVideo = async () => {
const validate = (): boolean => { const validate = (): boolean => {
errors.value = {}; errors.value = {};
if (!form.value.title.trim()) { if (!form.value.title.trim()) {
errors.value.title = 'Title is required.'; errors.value.title = t('video.detailModal.errors.titleRequired');
} }
return Object.keys(errors.value).length === 0; return Object.keys(errors.value).length === 0;
}; };
@@ -61,18 +104,44 @@ const onFormSubmit = async () => {
if (!validate()) return; if (!validate()) return;
saving.value = true; saving.value = true;
try { try {
await updateMockVideo(props.videoId, form.value); const payload: Record<string, any> = {
title: form.value.title,
};
if (video.value) { if (!isFreePlan.value) {
video.value.title = form.value.title; payload.ad_template_id = form.value.adTemplateId || '';
video.value.description = form.value.description;
} }
toast.add({ severity: 'success', summary: 'Success', detail: 'Video updated successfully', life: 3000 }); const response = await client.videos.videosUpdate(props.videoId, payload as any, { baseUrl: '/r' });
const data = (response.data as any)?.data;
const updatedVideo = data?.video as ModelVideo | undefined;
const updatedAdConfig = data?.ad_config as AdConfigPayload | undefined;
if (updatedVideo) {
video.value = updatedVideo;
currentAdConfig.value = updatedAdConfig || null;
form.value = {
title: updatedVideo.title || '',
adTemplateId: updatedAdConfig?.ad_template_id || '',
};
}
toast.add({
severity: 'success',
summary: t('video.detailModal.toast.saveSuccessSummary'),
detail: t('video.detailModal.toast.saveSuccessDetail'),
life: 3000
});
emit('close'); emit('close');
} catch (error) { } catch (error) {
console.error('Failed to save video:', error); console.error('Failed to save video:', error);
toast.add({ severity: 'error', summary: 'Error', detail: 'Failed to save changes', life: 3000 }); toast.add({
severity: 'error',
summary: t('video.detailModal.toast.saveErrorSummary'),
detail: t('video.detailModal.toast.saveErrorDetail'),
life: 3000
});
} finally { } finally {
saving.value = false; saving.value = false;
} }
@@ -89,20 +158,26 @@ const canUploadSubtitle = computed(() => {
const handleUploadSubtitle = () => { const handleUploadSubtitle = () => {
if (!canUploadSubtitle.value) return; if (!canUploadSubtitle.value) return;
toast.add({ severity: 'info', summary: 'Info', detail: 'Subtitle upload not yet implemented', life: 3000 }); toast.add({
severity: 'info',
summary: t('video.detailModal.toast.subtitleInfoSummary'),
detail: t('video.detailModal.toast.subtitleInfoDetail'),
life: 3000
});
}; };
watch(() => props.videoId, (newId) => { watch(() => props.videoId, (newId) => {
if (newId) { if (newId) {
errors.value = {}; errors.value = {};
fetchVideo(); fetchVideo();
fetchAdTemplates();
} }
}, { immediate: true }); }, { immediate: true });
</script> </script>
<template> <template>
<AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl" <AppDialog :visible="!!videoId" @update:visible="emit('close')" max-width-class="max-w-xl"
:title="loading ? '' : 'Edit video'"> :title="loading ? '' : $t('video.detailModal.title')">
<!-- Loading Skeleton --> <!-- Loading Skeleton -->
<div v-if="loading" class="flex flex-col gap-4"> <div v-if="loading" class="flex flex-col gap-4">
@@ -111,10 +186,10 @@ watch(() => props.videoId, (newId) => {
<div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" /> <div class="w-12 h-3.5 bg-gray-200 rounded animate-pulse" />
<div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" /> <div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
<!-- Description skeleton --> <!-- ad-template selector skeleton -->
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="w-20 h-3.5 bg-gray-200 rounded animate-pulse" /> <div class="w-24 h-3.5 bg-gray-200 rounded animate-pulse" />
<div class="w-full h-24 bg-gray-200 rounded-md animate-pulse" /> <div class="w-full h-10 bg-gray-200 rounded-md animate-pulse" />
</div> </div>
<!-- Subtitles section skeleton --> <!-- Subtitles section skeleton -->
<div class="flex flex-col gap-3 border-t border-gray-200 pt-4"> <div class="flex flex-col gap-3 border-t border-gray-200 pt-4">
@@ -144,37 +219,57 @@ watch(() => props.videoId, (newId) => {
<form v-else @submit.prevent="onFormSubmit" class="flex flex-col gap-4"> <form v-else @submit.prevent="onFormSubmit" class="flex flex-col gap-4">
<!-- Title --> <!-- Title -->
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="edit-title" class="text-sm font-medium">Title</label> <label for="edit-title" class="text-sm font-medium">{{ t('video.detailModal.titleLabel') }}</label>
<AppInput id="edit-title" v-model="form.title" placeholder="Enter video title" /> <AppInput id="edit-title" v-model="form.title" :placeholder="t('video.detailModal.titlePlaceholder')" />
<p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p> <p v-if="errors.title" class="text-xs text-red-500 mt-0.5">{{ errors.title }}</p>
</div> </div>
<!-- Description --> <!-- Ad Template Selector -->
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="edit-description" class="text-sm font-medium">Description</label> <label for="edit-ad-template" class="text-sm font-medium">{{ t('video.detailModal.adTemplateLabel') }}</label>
<textarea id="edit-description" v-model="form.description" placeholder="Enter video description" <select
rows="4" id="edit-ad-template"
class="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent resize-y" /> v-model="form.adTemplateId"
<p v-if="errors.description" class="text-xs text-red-500 mt-0.5">{{ errors.description }}</p> :disabled="isFreePlan || saving"
class="w-full px-3 py-2 border rounded-lg text-sm transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:border-transparent"
:class="isFreePlan
? 'border-border bg-muted/50 text-foreground/50 cursor-not-allowed'
: 'border-border bg-background text-foreground cursor-pointer hover:border-primary/50'"
>
<option value="">{{ t('video.detailModal.adTemplateNone') }}</option>
<option
v-for="tmpl in activeTemplates"
:key="tmpl.id"
:value="tmpl.id"
>
{{ tmpl.name }}{{ tmpl.is_default ? ` (${t('video.detailModal.adTemplateDefault')})` : '' }}
</option>
</select>
<p v-if="isFreePlan" class="text-xs text-foreground/50 mt-0.5">
{{ t('video.detailModal.adTemplateUpgradeHint') }}
</p>
<p v-else-if="!form.adTemplateId" class="text-xs text-foreground/50 mt-0.5">
{{ t('video.detailModal.adTemplateNoAdsHint') }}
</p>
</div> </div>
<!-- Subtitles Section --> <!-- Subtitles Section -->
<div class="flex flex-col gap-3 border-t-2 border-gray-200 pt-4"> <div class="flex flex-col gap-3 border-t-2 border-border pt-4">
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<label class="text-sm font-medium">Subtitles</label> <label class="text-sm font-medium">{{ t('video.detailModal.subtitlesTitle') }}</label>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800"> <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-muted text-foreground/70">
0 tracks {{ t('video.detailModal.subtitleTracks', { count: 0 }) }}
</span> </span>
</div> </div>
<p class="text-sm text-muted-foreground">No subtitles uploaded yet</p> <p class="text-sm text-muted-foreground">{{ t('video.detailModal.noSubtitles') }}</p>
<!-- Upload Subtitle Form --> <!-- Upload Subtitle Form -->
<div class="flex flex-col gap-3 rounded-lg border border-gray-200 p-3"> <div class="flex flex-col gap-3 rounded-lg border border-border p-3">
<label class="text-sm font-medium">Upload Subtitle</label> <label class="text-sm font-medium">{{ t('video.detailModal.uploadSubtitle') }}</label>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="subtitle-file" class="text-xs font-medium"> <label for="subtitle-file" class="text-xs font-medium">
Subtitle File (VTT, SRT, ASS, SSA) {{ t('video.detailModal.subtitleFile') }}
</label> </label>
<input id="subtitle-file" type="file" <input id="subtitle-file" type="file"
accept=".vtt,.srt,.ass,.ssa,text/vtt,text/srt,application/x-subrip" accept=".vtt,.srt,.ass,.ssa,text/vtt,text/srt,application/x-subrip"
@@ -184,27 +279,27 @@ watch(() => props.videoId, (newId) => {
<div class="grid grid-cols-2 gap-3"> <div class="grid grid-cols-2 gap-3">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="subtitle-language" class="text-xs font-medium">Language Code *</label> <label for="subtitle-language" class="text-xs font-medium">{{ t('video.detailModal.languageCode') }}</label>
<AppInput id="subtitle-language" v-model="subtitleForm.language" placeholder="en, vi, etc." <AppInput id="subtitle-language" v-model="subtitleForm.language" :placeholder="t('video.detailModal.languagePlaceholder')"
:maxlength="10" /> :maxlength="10" />
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="subtitle-name" class="text-xs font-medium">Display Name (Optional)</label> <label for="subtitle-name" class="text-xs font-medium">{{ t('video.detailModal.displayName') }}</label>
<AppInput id="subtitle-name" v-model="subtitleForm.displayName" <AppInput id="subtitle-name" v-model="subtitleForm.displayName"
placeholder="English, Tiếng Việt, etc." /> :placeholder="t('video.detailModal.displayNamePlaceholder')" />
</div> </div>
</div> </div>
<AppButton variant="secondary" class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle"> <AppButton variant="secondary" class="w-full" :disabled="!canUploadSubtitle" @click="handleUploadSubtitle">
Upload Subtitle {{ t('video.detailModal.uploadSubtitleButton') }}
</AppButton> </AppButton>
</div> </div>
</div> </div>
<!-- Footer inside Form so submit works --> <!-- Footer inside Form so submit works -->
<div class="flex justify-end gap-2 border-t border-gray-200 pt-4"> <div class="flex justify-end gap-2 border-t border-border pt-4">
<AppButton variant="ghost" type="button" @click="emit('close')">Cancel</AppButton> <AppButton variant="ghost" type="button" @click="emit('close')">{{ t('video.detailModal.cancel') }}</AppButton>
<AppButton type="submit" :loading="saving">Save Changes</AppButton> <AppButton type="submit" :loading="saving">{{ t('video.detailModal.saveChanges') }}</AppButton>
</div> </div>
</form> </form>
</AppDialog> </AppDialog>

Some files were not shown because too many files have changed in this diff Show More