Compare commits
16 Commits
develop-ki
...
develop-go
| Author | SHA1 | Date | |
|---|---|---|---|
| e1ba24d1bf | |||
| 77ece5224d | |||
| 16caa9281b | |||
| cd9aab8979 | |||
| c6924afe5b | |||
| a5b4028bc8 | |||
| ff1d4902bc | |||
| 00bbe0f503 | |||
| d6183d208e | |||
| 1a3dc948a8 | |||
| 718554dee9 | |||
| 85af2da6ad | |||
| 66028d934a | |||
| 4d41d6540a | |||
| 1ee2130d88 | |||
| 27a765044d |
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(bun run build)",
|
||||
"mcp__ide__getDiagnostics",
|
||||
"Bash(bun install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
374
AGENTS.md
Normal file
374
AGENTS.md
Normal file
@@ -0,0 +1,374 @@
|
||||
# AGENTS.md
|
||||
|
||||
This file provides guidance for AI coding agents working with the Holistream codebase.
|
||||
|
||||
## 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.
|
||||
|
||||
### Key Characteristics
|
||||
|
||||
- **Type**: Full-stack web application with SSR
|
||||
- **Primary Language**: TypeScript
|
||||
- **Package Manager**: Bun (evident from `bun.lock`)
|
||||
- **Deployment Target**: Cloudflare Workers
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Category | Technology | Version |
|
||||
|----------|------------|---------|
|
||||
| Framework | Vue | 3.5.27 |
|
||||
| Router | Vue Router | 5.0.2 |
|
||||
| Server Framework | Hono | 4.11.7 |
|
||||
| Build Tool | Vite | 7.3.1 |
|
||||
| CSS Framework | UnoCSS | 66.6.0 |
|
||||
| UI Components | PrimeVue | 4.5.4 |
|
||||
| State Management | Pinia | 3.0.4 |
|
||||
| Server State | Pinia Colada | 0.21.2 |
|
||||
| Meta/SEO | @unhead/vue | 2.1.2 |
|
||||
| Utilities | VueUse | 14.2.0 |
|
||||
| Validation | Zod | 4.3.6 |
|
||||
| Deployment | Wrangler | 4.62.0 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/
|
||||
│ ├── api/ # API client and HTTP adapters
|
||||
│ │ ├── client.ts # Auto-generated API client from OpenAPI spec
|
||||
│ │ ├── httpClientAdapter.client.ts # Client-side fetch adapter
|
||||
│ │ └── httpClientAdapter.server.ts # Server-side fetch adapter
|
||||
│ ├── client.ts # Client entry point (hydration)
|
||||
│ ├── components/ # Vue components
|
||||
│ │ ├── dashboard/ # Dashboard-specific components
|
||||
│ │ ├── icons/ # Custom icon components
|
||||
│ │ ├── ui/ # UI primitive components
|
||||
│ │ ├── ClientOnly.tsx # SSR-safe client-only wrapper
|
||||
│ │ ├── DashboardLayout.vue # Main dashboard layout
|
||||
│ │ ├── GlobalUploadIndicator.vue
|
||||
│ │ ├── NotificationDrawer.vue
|
||||
│ │ └── RootLayout.vue # Root application layout
|
||||
│ ├── composables/ # Vue composables
|
||||
│ │ └── useUploadQueue.ts # Upload queue management
|
||||
│ ├── index.tsx # Server entry point (Hono app)
|
||||
│ ├── lib/ # Utility libraries
|
||||
│ │ ├── constants.ts # Application constants
|
||||
│ │ ├── directives/ # Custom Vue directives
|
||||
│ │ ├── hoc/ # Higher-order components
|
||||
│ │ ├── interface.ts # TypeScript interfaces
|
||||
│ │ ├── liteMqtt.ts # MQTT client (browser)
|
||||
│ │ ├── manifest.ts # Vite manifest utilities
|
||||
│ │ ├── PiniaSharedState.ts # Pinia state hydration plugin
|
||||
│ │ ├── primePassthrough.ts # PrimeVue theme configuration
|
||||
│ │ ├── replateStreamText.ts
|
||||
│ │ └── utils.ts # Utility functions (cn, formatters, etc.)
|
||||
│ ├── main.ts # App factory function
|
||||
│ ├── mocks/ # Mock data for development
|
||||
│ ├── routes/ # Route components (page components)
|
||||
│ │ ├── auth/ # Authentication pages
|
||||
│ │ ├── home/ # Public pages (landing, terms, privacy)
|
||||
│ │ ├── notification/ # Notification page
|
||||
│ │ ├── overview/ # Dashboard overview
|
||||
│ │ ├── plans/ # Payments & plans
|
||||
│ │ ├── profile/ # User profile
|
||||
│ │ ├── upload/ # Video upload
|
||||
│ │ ├── video/ # Video management
|
||||
│ │ ├── index.ts # Router configuration
|
||||
│ │ └── NotFound.vue # 404 page
|
||||
│ ├── server/ # Server-side utilities
|
||||
│ │ └── modules/
|
||||
│ │ └── merge.ts # Video chunk merge logic
|
||||
│ ├── stores/ # Pinia stores
|
||||
│ │ └── auth.ts # Authentication store
|
||||
│ ├── type.d.ts # TypeScript declarations
|
||||
│ └── worker/ # Worker utilities
|
||||
│ ├── html.ts
|
||||
│ └── ssrLayout.ts
|
||||
├── bootstrap_btn.ts # Bootstrap button preset for UnoCSS
|
||||
├── ssrPlugin.ts # Custom Vite SSR plugin
|
||||
├── uno.config.ts # UnoCSS configuration
|
||||
├── vite.config.ts # Vite configuration
|
||||
├── wrangler.jsonc # Cloudflare Workers configuration
|
||||
├── tsconfig.json # TypeScript configuration
|
||||
├── package.json # Package dependencies
|
||||
├── bun.lock # Bun lock file
|
||||
├── docs.json # OpenAPI/Swagger spec for API
|
||||
├── auto-imports.d.ts # Auto-generated type declarations
|
||||
└── components.d.ts # Auto-generated component declarations
|
||||
```
|
||||
|
||||
## Build and Development Commands
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
bun install
|
||||
|
||||
# Start development server with hot reload
|
||||
bun dev
|
||||
|
||||
# Build for production (client + worker)
|
||||
bun run build
|
||||
|
||||
# Preview production build locally
|
||||
bun preview
|
||||
|
||||
# Deploy to Cloudflare Workers
|
||||
bun run deploy
|
||||
|
||||
# Generate TypeScript types from Wrangler config
|
||||
bun run cf-typegen
|
||||
|
||||
# View Cloudflare Worker logs
|
||||
bun run tail
|
||||
```
|
||||
|
||||
> **Note**: While npm commands work (`npm run dev`, etc.), the project uses Bun as its primary package manager.
|
||||
|
||||
## Architecture Details
|
||||
|
||||
### SSR Architecture
|
||||
|
||||
The application uses a custom SSR setup defined in `ssrPlugin.ts`:
|
||||
|
||||
1. **Build Order**: Client bundle is built FIRST, then the Worker bundle
|
||||
2. **Manifest Injection**: Vite manifest is injected into the server build for asset rendering
|
||||
3. **Environment-based Resolution**: `httpClientAdapter` and `liteMqtt` resolve to different implementations based on SSR context
|
||||
|
||||
**Entry Points:**
|
||||
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream
|
||||
- **Client**: `src/client.ts` - Hydrates the SSR-rendered application
|
||||
- **App Factory**: `src/main.ts` - Creates the Vue app instance (used by both)
|
||||
|
||||
### State Management with SSR
|
||||
|
||||
Uses **Pinia Colada** for server state with SSR hydration:
|
||||
|
||||
- Server-side queries are fetched and serialized to `window.__APP_DATA__`
|
||||
- Client hydrates the query cache via `hydrateQueryCache()`
|
||||
- Pinia state is serialized and restored via `PiniaSharedState` plugin
|
||||
|
||||
### Module Aliases
|
||||
|
||||
Configured in `tsconfig.json` and `vite.config.ts`:
|
||||
|
||||
| Alias | Resolution |
|
||||
|-------|------------|
|
||||
| `@/` | `src/` |
|
||||
| `@httpClientAdapter` | `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) |
|
||||
| `@liteMqtt` | `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) |
|
||||
|
||||
### API Client Architecture
|
||||
|
||||
The API client (`src/api/client.ts`) is **auto-generated** from the OpenAPI spec (`docs.json`):
|
||||
|
||||
- Uses `customFetch` adapter that differs between client/server
|
||||
- **Server adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, proxies to `api.pipic.fun`
|
||||
- **Client adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials
|
||||
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun`
|
||||
|
||||
### Routing Structure
|
||||
|
||||
Routes are defined in `src/routes/index.ts` with three main layout groups:
|
||||
|
||||
1. **Public** (`/`): Landing page, terms, privacy
|
||||
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in)
|
||||
3. **Dashboard**: Protected routes requiring authentication
|
||||
- `/overview` - Main dashboard
|
||||
- `/upload` - Video upload with queue management
|
||||
- `/video` - Video list
|
||||
- `/video/:id` - Video detail/edit
|
||||
- `/payments-and-plans` - Billing management
|
||||
- `/notification`, `/profile` - User settings
|
||||
|
||||
Route meta supports `@unhead/vue` for SEO:
|
||||
```ts
|
||||
meta: {
|
||||
head: {
|
||||
title: "Page Title",
|
||||
meta: [{ name: "description", content: "..." }]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Styling System (UnoCSS)
|
||||
|
||||
Configuration in `uno.config.ts`:
|
||||
|
||||
- **Presets**: Wind4 (Tailwind-like), Typography, Attributify, Bootstrap buttons
|
||||
- **Custom Colors**:
|
||||
- `primary` (#14a74b)
|
||||
- `secondary` (#fd7906)
|
||||
- `accent`, `success`, `info`, `warning`, `danger`
|
||||
- **Shortcuts**: `press-animated` for button press effects
|
||||
- **Transformers**:
|
||||
- `transformerCompileClass` (prefix: `_` for compiled classes)
|
||||
- `transformerVariantGroup`
|
||||
|
||||
Use `cn()` from `src/lib/utils.ts` for conditional class merging (combines `clsx` + `tailwind-merge`).
|
||||
|
||||
### Component Auto-Import
|
||||
|
||||
Components are auto-imported via `unplugin-vue-components`:
|
||||
|
||||
- PrimeVue components resolved via `PrimeVueResolver`
|
||||
- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import`
|
||||
- Type declarations auto-generated to `components.d.ts` and `auto-imports.d.ts`
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### Code Style
|
||||
|
||||
- **TypeScript**: Strict mode enabled
|
||||
- **JSX/TSX**: Supported for components (import source: `vue`)
|
||||
- **CSS**: Use UnoCSS utility classes; custom CSS in component `<style>` blocks when needed
|
||||
|
||||
### File Organization
|
||||
|
||||
- Page components go in `src/routes/` following the route structure
|
||||
- Reusable components go in `src/components/`
|
||||
- Composables go in `src/composables/`
|
||||
- Stores go in `src/stores/`
|
||||
- Server utilities go in `src/server/`
|
||||
|
||||
### HTTP Requests
|
||||
|
||||
**Always use the generated API client** instead of raw fetch:
|
||||
|
||||
```ts
|
||||
import { client } from '@/api/client';
|
||||
|
||||
// Example
|
||||
const response = await client.auth.loginCreate({ email, password });
|
||||
```
|
||||
|
||||
The client handles:
|
||||
- Base URL resolution
|
||||
- Cookie forwarding (server-side)
|
||||
- Type safety
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
- `useAuthStore` manages auth state with cookie-based sessions
|
||||
- `init()` is 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 (90MB chunks, max 3 parallel)
|
||||
- Progress tracking with speed calculation
|
||||
|
||||
### Type Safety
|
||||
|
||||
- TypeScript strict mode enabled
|
||||
- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`)
|
||||
- API types auto-generated from backend OpenAPI spec (`docs.json`)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Cloudflare Worker Bindings
|
||||
|
||||
Configured in `wrangler.jsonc`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "holistream",
|
||||
"compatibility_date": "2025-08-03",
|
||||
"compatibility_flags": ["nodejs_compat"],
|
||||
"observability": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
- No explicit secrets in code - use Wrangler secrets management
|
||||
- Access environment variables via Hono context: `c.env.VAR_NAME`
|
||||
|
||||
### Local Environment
|
||||
|
||||
Create `.dev.vars` for local development secrets (do not commit):
|
||||
|
||||
```
|
||||
SECRET_KEY=...
|
||||
```
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
**Current Status**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
|
||||
|
||||
When adding tests or linting:
|
||||
- Add appropriate dev dependencies
|
||||
- Update this section with commands and conventions
|
||||
- Consider the SSR environment when writing tests
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Cookie Security**: Cookies are httpOnly, secure, and sameSite
|
||||
2. **CORS**: Configured via Hono's CORS middleware
|
||||
3. **API Proxy**: Backend API is never exposed directly to the browser; all requests go through `/r/*` proxy
|
||||
4. **Input Validation**: Use Zod for runtime validation
|
||||
5. **XSS Protection**: HTML escaping is applied to SSR data via `htmlEscape()` function
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Creating a New Page
|
||||
|
||||
1. Create component in `src/routes/<section>/PageName.vue`
|
||||
2. Add route to `src/routes/index.ts` with appropriate meta
|
||||
3. Use `head` in route meta for SEO if needed
|
||||
|
||||
### Using the Upload Queue
|
||||
|
||||
```ts
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
|
||||
const { items, addFiles, addRemoteUrls, startQueue } = useUploadQueue();
|
||||
```
|
||||
|
||||
### Accessing Hono Context in Components
|
||||
|
||||
```ts
|
||||
import { inject } from 'vue';
|
||||
|
||||
const honoContext = inject('honoContext');
|
||||
```
|
||||
|
||||
### Conditional Classes
|
||||
|
||||
```ts
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const className = cn(
|
||||
'base-class',
|
||||
isActive && 'active-class',
|
||||
variant === 'primary' ? 'text-primary' : 'text-secondary'
|
||||
);
|
||||
```
|
||||
|
||||
## External Dependencies
|
||||
|
||||
- **Backend API**: `https://api.pipic.fun`
|
||||
- **MQTT Broker**: `wss://mqtt-dashboard.com:8884/mqtt`
|
||||
- **Fonts**: Google Fonts (Google Sans loaded from fonts.googleapis.com)
|
||||
|
||||
## Important Files Reference
|
||||
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
*This document was generated for AI coding agents. For human contributors, see README.md.*
|
||||
198
CLAUDE.md
Normal file
198
CLAUDE.md
Normal file
@@ -0,0 +1,198 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## 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.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Framework**: Vue 3 with JSX/TSX support
|
||||
- **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
|
||||
# Development server with hot reload
|
||||
bun dev
|
||||
|
||||
# Production build (client + worker)
|
||||
bun run build
|
||||
|
||||
# Preview production build locally
|
||||
bun preview
|
||||
|
||||
# Deploy to Cloudflare Workers
|
||||
bun run deploy
|
||||
|
||||
# Generate TypeScript types from Wrangler config
|
||||
bun run cf-typegen
|
||||
|
||||
# View Cloudflare Worker logs
|
||||
bun run tail
|
||||
```
|
||||
|
||||
**Note**: The project uses Bun as the package manager. If using npm/yarn, replace `bun` with `npm run` or `yarn`.
|
||||
|
||||
## Architecture
|
||||
|
||||
### SSR Architecture
|
||||
|
||||
The app uses a custom SSR setup (`ssrPlugin.ts`) that:
|
||||
- Builds the client bundle FIRST, then the Worker bundle
|
||||
- Injects the Vite manifest into the server build for asset rendering
|
||||
- Uses environment-based module resolution for `httpClientAdapter` and `liteMqtt`
|
||||
|
||||
Entry points:
|
||||
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream
|
||||
- **Client**: `src/client.ts` - Hydrates the SSR-rendered app
|
||||
|
||||
### Module Aliases
|
||||
|
||||
- `@/` → `src/`
|
||||
- `@httpClientAdapter` → `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser)
|
||||
- `@liteMqtt` → `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser)
|
||||
|
||||
### State Management Pattern
|
||||
|
||||
Uses **Pinia Colada** for server state with SSR hydration:
|
||||
- Queries are fetched server-side and serialized to `window.__APP_DATA__`
|
||||
- Client hydrates the query cache on startup via `hydrateQueryCache()`
|
||||
- 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)
|
||||
98
components.d.ts
vendored
98
components.d.ts
vendored
@@ -12,103 +12,157 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
||||
Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
Globe: typeof import('./src/components/icons/Globe.vue')['default']
|
||||
GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
|
||||
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
|
||||
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
IconField: typeof import('primevue/iconfield')['default']
|
||||
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
InputIcon: typeof import('primevue/inputicon')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
Paginator: typeof import('primevue/paginator')['default']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
|
||||
PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
|
||||
PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
|
||||
RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
|
||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
Select: typeof import('primevue/select')['default']
|
||||
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
Skeleton: typeof import('primevue/skeleton')['default']
|
||||
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
|
||||
VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
|
||||
VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
|
||||
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
// For TSX support
|
||||
declare global {
|
||||
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
||||
const Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
const AppButton: typeof import('./src/components/app/AppButton.vue')['default']
|
||||
const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
|
||||
const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
|
||||
const AppInput: typeof import('./src/components/app/AppInput.vue')['default']
|
||||
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
|
||||
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
|
||||
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
|
||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||
const Button: typeof import('primevue/button')['default']
|
||||
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
const FloatLabel: typeof import('primevue/floatlabel')['default']
|
||||
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
const Globe: typeof import('./src/components/icons/Globe.vue')['default']
|
||||
const GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
|
||||
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
|
||||
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
|
||||
const Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||
const IconField: typeof import('primevue/iconfield')['default']
|
||||
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
const InputIcon: typeof import('primevue/inputicon')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
|
||||
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||
const Paginator: typeof import('primevue/paginator')['default']
|
||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||
const Password: typeof import('primevue/password')['default']
|
||||
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
||||
const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
|
||||
const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
|
||||
const PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
|
||||
const RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
|
||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const Select: typeof import('primevue/select')['default']
|
||||
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
const Skeleton: typeof import('primevue/skeleton')['default']
|
||||
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
|
||||
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
|
||||
const Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
const VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
|
||||
const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
|
||||
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
|
||||
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
||||
}
|
||||
36
package.json
36
package.json
@@ -2,45 +2,37 @@
|
||||
"name": "holistream",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"dev": "bun vite",
|
||||
"build": "bun vite build",
|
||||
"preview": "bun vite preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||
"tail": "wrangler tail"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.971.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.971.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.971.0",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@primeuix/themes": "^2.0.3",
|
||||
"@primevue/forms": "^4.5.4",
|
||||
"@pinia/colada": "^0.21.2",
|
||||
"@unhead/vue": "^2.1.2",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@vueuse/core": "^14.2.0",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"clsx": "^2.1.1",
|
||||
"firebase-admin": "^13.6.0",
|
||||
"hono": "^4.11.4",
|
||||
"hono": "^4.11.7",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primevue": "^4.5.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vue": "^3.5.27",
|
||||
"vue-router": "^4.6.4",
|
||||
"zod": "^4.3.5"
|
||||
"vue-router": "^5.0.2",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.21.0",
|
||||
"@primevue/auto-import-resolver": "^4.5.4",
|
||||
"@types/node": "^25.0.9",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
"@cloudflare/vite-plugin": "^1.23.0",
|
||||
"@types/node": "^25.2.0",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"unocss": "^66.6.0",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^31.0.0",
|
||||
"vite": "^7.3.1",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.59.2"
|
||||
"wrangler": "^4.62.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
export const baseAPIURL = "https://api.pipic.fun";
|
||||
export const customFetch = (url: string, options: RequestInit) => {
|
||||
options.credentials = "include";
|
||||
const c = tryGetContext<any>();
|
||||
@@ -21,7 +21,7 @@ export const customFetch = (url: string, options: RequestInit) => {
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
|
||||
const apiUrl = [baseAPIURL, url.replace(/^r/, "")].join("");
|
||||
return fetch(apiUrl, options).then(async (res) => {
|
||||
res.headers.getSetCookie()?.forEach((cookie) => {
|
||||
c.header("Set-Cookie", cookie);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { HonoVarTypes } from "types";
|
||||
|
||||
// We can keep checkAuth to return the current user profile from the context
|
||||
// which is populated by the firebaseAuthMiddleware
|
||||
async function checkAuth() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const user = context.get('user');
|
||||
|
||||
if (!user) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: user
|
||||
};
|
||||
}
|
||||
|
||||
export const authMethods = {
|
||||
checkAuth,
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export const secret = "123_it-is-very-secret_123";
|
||||
@@ -1,344 +0,0 @@
|
||||
import {
|
||||
exposeTinyRpc,
|
||||
httpServerAdapter,
|
||||
validateFn,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
import { MiddlewareHandler, type Context, type Next } from "hono";
|
||||
import { getContext } from "hono/context-storage";
|
||||
// import { adminAuth } from "../../lib/firebaseAdmin";
|
||||
import { z } from "zod";
|
||||
import { authMethods } from "./auth";
|
||||
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
|
||||
// import { createElement } from "react";
|
||||
|
||||
let counter = 0;
|
||||
const listCourses = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Lập trình Web Fullstack",
|
||||
description:
|
||||
"Học cách xây dựng ứng dụng web hoàn chỉnh từ frontend đến backend. Khóa học bao gồm HTML, CSS, JavaScript, React, Node.js và MongoDB.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.200.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Web%20Fullstack",
|
||||
slug: "lap-trinh-web-fullstack",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Phân tích dữ liệu với Python",
|
||||
description:
|
||||
"Khám phá sức mạnh của Python trong việc phân tích và trực quan hóa dữ liệu. Sử dụng Pandas, NumPy, Matplotlib và Seaborn.",
|
||||
category: "Phân tích dữ liệu",
|
||||
rating: 4.8,
|
||||
price: "900.000 VNĐ",
|
||||
icon: "fas fa-chart-bar",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Data%20Analysis",
|
||||
slug: "phan-tich-du-lieu-voi-python",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Thiết kế UI/UX chuyên nghiệp",
|
||||
description:
|
||||
"Học các nguyên tắc thiết kế giao diện và trải nghiệm người dùng hiện đại. Sử dụng Figma và Adobe XD.",
|
||||
category: "Thiết kế",
|
||||
rating: 4.7,
|
||||
price: "800.000 VNĐ",
|
||||
icon: "fas fa-paint-brush",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=UI/UX%20Design",
|
||||
slug: "thiet-ke-ui-ux-chuyen-nghiep",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Machine Learning cơ bản",
|
||||
description:
|
||||
"Nhập môn Machine Learning với Python. Tìm hiểu về các thuật toán học máy cơ bản như Linear Regression, Logistic Regression, Decision Trees.",
|
||||
category: "AI/ML",
|
||||
rating: 4.6,
|
||||
price: "1.500.000 VNĐ",
|
||||
icon: "fas fa-brain",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Machine%20Learning",
|
||||
slug: "machine-learning-co-ban",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: "Digital Marketing toàn diện",
|
||||
description:
|
||||
"Chiến lược Marketing trên các nền tảng số. SEO, Google Ads, Facebook Ads và Content Marketing.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "700.000 VNĐ",
|
||||
icon: "fas fa-bullhorn",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Digital%20Marketing",
|
||||
slug: "digital-marketing-toan-dien",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: "Lập trình Mobile với Flutter",
|
||||
description:
|
||||
"Xây dựng ứng dụng di động đa nền tảng (iOS & Android) với Flutter và Dart.",
|
||||
category: "Lập trình",
|
||||
rating: 4.8,
|
||||
price: "1.100.000 VNĐ",
|
||||
icon: "fas fa-mobile-alt",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Flutter%20Mobile",
|
||||
slug: "lap-trinh-mobile-voi-flutter",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: "Tiếng Anh giao tiếp công sở",
|
||||
description:
|
||||
"Cải thiện kỹ năng giao tiếp tiếng Anh trong môi trường làm việc chuyên nghiệp.",
|
||||
category: "Ngoại ngữ",
|
||||
rating: 4.4,
|
||||
price: "600.000 VNĐ",
|
||||
icon: "fas fa-language",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Business%20English",
|
||||
slug: "tieng-anh-giao-tiep-cong-so",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
title: "Quản trị dự án Agile/Scrum",
|
||||
description:
|
||||
"Phương pháp quản lý dự án linh hoạt Agile và khung làm việc Scrum.",
|
||||
category: "Kỹ năng mềm",
|
||||
rating: 4.7,
|
||||
price: "950.000 VNĐ",
|
||||
icon: "fas fa-tasks",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Agile%20Scrum",
|
||||
slug: "quan-tri-du-an-agile-scrum",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
title: "Nhiếp ảnh cơ bản",
|
||||
description:
|
||||
"Làm chủ máy ảnh và nghệ thuật nhiếp ảnh. Bố cục, ánh sáng và chỉnh sửa ảnh.",
|
||||
category: "Nghệ thuật",
|
||||
rating: 4.9,
|
||||
price: "500.000 VNĐ",
|
||||
icon: "fas fa-camera",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Photography",
|
||||
slug: "nhiep-anh-co-ban",
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
title: "Blockchain 101",
|
||||
description:
|
||||
"Hiểu về công nghệ Blockchain, Bitcoin, Ethereum và Smart Contracts.",
|
||||
category: "Công nghệ",
|
||||
rating: 4.6,
|
||||
price: "1.300.000 VNĐ",
|
||||
icon: "fas fa-link",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Blockchain",
|
||||
slug: "blockchain-101",
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
title: "ReactJS Nâng cao",
|
||||
description:
|
||||
"Các kỹ thuật nâng cao trong React: Hooks, Context, Redux, Performance Optimization.",
|
||||
category: "Lập trình",
|
||||
rating: 4.9,
|
||||
price: "1.000.000 VNĐ",
|
||||
icon: "fas fa-code",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Advanced%20React",
|
||||
slug: "reactjs-nang-cao",
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
title: "Viết Content Marketing thu hút",
|
||||
description:
|
||||
"Kỹ thuật viết bài chuẩn SEO, thu hút người đọc và tăng tỷ lệ chuyển đổi.",
|
||||
category: "Marketing",
|
||||
rating: 4.5,
|
||||
price: "550.000 VNĐ",
|
||||
icon: "fas fa-pen-nib",
|
||||
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Content%20Marketing",
|
||||
slug: "viet-content-marketing",
|
||||
}
|
||||
];
|
||||
|
||||
const courseContent = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Giới thiệu khóa học",
|
||||
type: "video",
|
||||
duration: "5:00",
|
||||
completed: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: "Cài đặt môi trường",
|
||||
type: "video",
|
||||
duration: "15:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: "Kiến thức cơ bản",
|
||||
type: "video",
|
||||
duration: "25:00",
|
||||
completed: false,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: "Bài tập thực hành 1",
|
||||
type: "quiz",
|
||||
duration: "10:00",
|
||||
completed: false,
|
||||
},
|
||||
];
|
||||
|
||||
const routes = {
|
||||
// define as a bare function
|
||||
checkId: (id: string) => {
|
||||
const context = getContext();
|
||||
console.log(context.req.raw.headers);
|
||||
return id === "good";
|
||||
},
|
||||
|
||||
checkIdThrow: (id: string) => {
|
||||
tinyassert(id === "good", "Invalid ID");
|
||||
return null;
|
||||
},
|
||||
|
||||
getCounter: () => {
|
||||
const context = getContext();
|
||||
console.log(context.get("jwtPayload"));
|
||||
return counter;
|
||||
},
|
||||
|
||||
// define with zod validation + input type inference
|
||||
incrementCounter: validateFn(z.object({ delta: z.number().default(1) }))(
|
||||
(input) => {
|
||||
// expectTypeOf(input).toEqualTypeOf<{ delta: number }>();
|
||||
counter += input.delta;
|
||||
return counter;
|
||||
}
|
||||
),
|
||||
|
||||
// access context
|
||||
components: async () => { },
|
||||
getHomeCourses: async () => {
|
||||
return listCourses.slice(0, 3);
|
||||
},
|
||||
getCourses: validateFn(
|
||||
z.object({
|
||||
page: z.number().default(1),
|
||||
limit: z.number().default(6),
|
||||
search: z.string().optional(),
|
||||
category: z.string().optional(),
|
||||
})
|
||||
)(async ({ page, limit, search, category }) => {
|
||||
let filtered = listCourses;
|
||||
|
||||
if (search) {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(c) =>
|
||||
c.title.toLowerCase().includes(lowerSearch) ||
|
||||
c.description.toLowerCase().includes(lowerSearch)
|
||||
);
|
||||
}
|
||||
|
||||
if (category && category !== "All") {
|
||||
filtered = filtered.filter((c) => c.category === category);
|
||||
}
|
||||
|
||||
const start = (page - 1) * limit;
|
||||
const end = start + limit;
|
||||
const paginated = filtered.slice(start, end);
|
||||
|
||||
return {
|
||||
data: paginated,
|
||||
total: filtered.length,
|
||||
page,
|
||||
totalPages: Math.ceil(filtered.length / limit),
|
||||
};
|
||||
}),
|
||||
getCourseBySlug: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
const course = listCourses.find((c) => c.slug === slug);
|
||||
if (!course) {
|
||||
throw new Error("Course not found");
|
||||
}
|
||||
return course;
|
||||
}),
|
||||
getCourseContent: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
|
||||
// In a real app, we would fetch content specific to the course
|
||||
return courseContent;
|
||||
}),
|
||||
presignedPut: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => imageContentTypes.includes(val), { message: "Invalid content type" }) }))(async ({ fileName, contentType }) => {
|
||||
return await presignedPut(fileName, contentType);
|
||||
}),
|
||||
chunkedUpload: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => videoContentTypes.includes(val), { message: "Invalid content type" }), fileSize: z.number().min(1024 * 10).max(3 * 1024 * 1024 * 1024).default(1024 * 256) }))(async ({ fileName, contentType, fileSize }) => {
|
||||
const key = nanoid() + "_" + fileName;
|
||||
const { UploadId } = await chunkedUpload(key, contentType, fileSize);
|
||||
const chunkSize = 1024 * 1024 * 20; // 20MB
|
||||
const presignedUrls = await createPresignedUrls({
|
||||
key,
|
||||
uploadId: UploadId!,
|
||||
totalParts: Math.ceil(fileSize / chunkSize),
|
||||
});
|
||||
return { uploadId: UploadId!, presignedUrls, chunkSize, key, totalParts: presignedUrls.length };
|
||||
}),
|
||||
completeChunk: validateFn(z.object({ key: z.string(), uploadId: z.string(), parts: z.array(z.object({ PartNumber: z.number(), ETag: z.string() })) }))(async ({ key, uploadId, parts }) => {
|
||||
await completeChunk(key, uploadId, parts);
|
||||
return { success: true };
|
||||
}),
|
||||
abortChunk: validateFn(z.object({ key: z.string(), uploadId: z.string() }))(async ({ key, uploadId }) => {
|
||||
await abortChunk(key, uploadId);
|
||||
return { success: true };
|
||||
}),
|
||||
...authMethods
|
||||
};
|
||||
export type RpcRoutes = typeof routes;
|
||||
export const endpoint = "/rpc";
|
||||
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
|
||||
|
||||
export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
|
||||
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
|
||||
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
|
||||
c.set("isPublic", isPublic);
|
||||
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
|
||||
return await next();
|
||||
}
|
||||
|
||||
const authHeader = c.req.header("Authorization");
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
// Option: return 401 or let it pass with no user?
|
||||
// Old logic seemed to require it for non-public paths.
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
const token = authHeader.split("Bearer ")[1];
|
||||
try {
|
||||
// const decodedToken = await adminAuth.verifyIdToken(token);
|
||||
// c.set("user", decodedToken);
|
||||
} catch (error) {
|
||||
console.error("Firebase Auth Error:", error);
|
||||
return c.json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
|
||||
export const rpcServer = async (c: Context, next: Next) => {
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
const cert = c.req.header()
|
||||
console.log("RPC Request Path:", c.req.raw.cf);
|
||||
// if (!cert) return c.text('Forbidden', 403)
|
||||
const handler = exposeTinyRpc({
|
||||
routes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const res = await handler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
return await next();
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
ListPartsCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { randomBytes } from "crypto";
|
||||
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
||||
|
||||
export function nanoid(size = 21) {
|
||||
let id = '';
|
||||
const bytes = randomBytes(size); // Node.js specific method
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
id += urlAlphabet[bytes[i] & 63];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
// createPresignedPost
|
||||
const S3 = new S3Client({
|
||||
region: "auto", // Required by SDK but not used by R2
|
||||
endpoint: `https://s3.cloudfly.vn`,
|
||||
credentials: {
|
||||
// accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
accessKeyId: "BD707P5W8J5DHFPUKYZ6",
|
||||
secretAccessKey: "LTX7IizSDn28XGeQaHNID2fOtagfLc6L2henrP6P",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
// const S3 = new S3Client({
|
||||
// region: "auto", // Required by SDK but not used by R2
|
||||
// endpoint: `https://u.pipic.fun`,
|
||||
// credentials: {
|
||||
// // accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// // secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
// accessKeyId: "cdnadmin",
|
||||
// secretAccessKey: "D@tkhong9",
|
||||
// },
|
||||
// forcePathStyle: true,
|
||||
// });
|
||||
export const imageContentTypes = ["image/png", "image/jpg", "image/jpeg", "image/webp"];
|
||||
export const videoContentTypes = ["video/mp4", "video/webm", "video/ogg", "video/*"];
|
||||
const nanoId = () => {
|
||||
// return crypto.randomUUID().replace(/-/g, "").slice(0, 10);
|
||||
return ""
|
||||
}
|
||||
export async function presignedPut(fileName: string, contentType: string){
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
const key = nanoId()+"_"+fileName;
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new PutObjectCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
// ContentLength: 31457280, // Max 30MB
|
||||
// ACL: "public-read", // Uncomment if you want the object to be publicly readable
|
||||
}),
|
||||
{ expiresIn: 600 } // URL valid for 10 minutes
|
||||
);
|
||||
return { url, key };
|
||||
}
|
||||
export async function createPresignedUrls({
|
||||
key,
|
||||
uploadId,
|
||||
totalParts,
|
||||
expiresIn = 60 * 15, // 15 phút
|
||||
}: {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
totalParts: number;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const urls = [];
|
||||
|
||||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(S3, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
urls.push({
|
||||
partNumber,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
export async function chunkedUpload(Key: string, contentType: string, fileSize: number) {
|
||||
// lớn hơn 3gb thì cút
|
||||
if (fileSize > 3 * 1024 * 1024 * 1024) {
|
||||
throw new Error("File size exceeds 3GB");
|
||||
}
|
||||
// CreateMultipartUploadCommand
|
||||
const uploadParams = {
|
||||
Bucket: "tmp",
|
||||
Key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
};
|
||||
let data = await S3.send(new CreateMultipartUploadCommand(uploadParams));
|
||||
return data;
|
||||
}
|
||||
export async function abortChunk(key: string, uploadId: string) {
|
||||
await S3.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function completeChunk(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
|
||||
const listed = await S3.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
if (!listed.Parts || listed.Parts.length !== parts.length) {
|
||||
throw new Error("Not all parts have been uploaded");
|
||||
}
|
||||
await S3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function deleteObject(bucketName: string, objectKey: string) {
|
||||
await S3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function listBuckets() {
|
||||
const data = await S3.send(new ListBucketsCommand({}));
|
||||
return data.Buckets;
|
||||
}
|
||||
export async function listObjects(bucketName: string) {
|
||||
const data = await S3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
})
|
||||
);
|
||||
return data.Contents;
|
||||
}
|
||||
export async function generateUploadForm(fileName: string, contentType: string) {
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
return await createPresignedPost(S3, {
|
||||
Bucket: "tmp",
|
||||
Key: nanoId()+"_"+fileName,
|
||||
Expires: 10 * 60, // URL valid for 10 minutes
|
||||
Conditions: [
|
||||
["starts-with", "$Content-Type", contentType],
|
||||
["content-length-range", 0, 31457280], // Max 30MB
|
||||
],
|
||||
});
|
||||
}
|
||||
// generateUploadUrl("tmp", "cat.png", "image/png").then(console.log);
|
||||
export async function createDownloadUrl(key: string): Promise<string> {
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new GetObjectCommand({ Bucket: "tmp", Key: key }),
|
||||
{ expiresIn: 600 } // 600 giây = 10 phút
|
||||
);
|
||||
return url;
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
import { createApp } from './main';
|
||||
import { hydrateQueryCache } from '@pinia/colada';
|
||||
import 'uno.css';
|
||||
import PiniaSharedState from './lib/PiniaSharedState';
|
||||
import { createApp } from './main';
|
||||
|
||||
async function render() {
|
||||
const { app, router } = createApp();
|
||||
const { app, router, queryCache, pinia } = createApp();
|
||||
pinia.use(PiniaSharedState({enable: true, initialize: true}));
|
||||
hydrateQueryCache(queryCache, (window as any).$colada || {});
|
||||
router.isReady().then(() => {
|
||||
app.mount('body', true)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import DashboardNav from "./DashboardNav.vue";
|
||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
import Upload from "@/routes/upload/Upload.vue";
|
||||
|
||||
</script>
|
||||
|
||||
@@ -20,5 +21,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
</router-view>
|
||||
</div>
|
||||
<GlobalUploadIndicator />
|
||||
<Upload />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
@@ -2,18 +2,14 @@
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import Credit from "@/components/icons/Credit.vue";
|
||||
import Upload from "@/components/icons/Upload.vue";
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
||||
// import Upload from "@/components/icons/Upload.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createStaticVNode, ref } from "vue";
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const profileHoist = createStaticVNode(`<div class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated">
|
||||
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
|
||||
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
|
||||
</div>`, 1);
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
|
||||
@@ -24,14 +20,14 @@ const handleNotificationClick = (event: Event) => {
|
||||
const links = [
|
||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||
// { href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||
{ href: "/videos", label: "Videos", icon: Video, type: "a", className },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
||||
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
|
||||
{ href: "/settings", label: "Settings", icon: SettingsIcon, type: "a", className },
|
||||
];
|
||||
|
||||
|
||||
//v-tooltip="i.label"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,17 +36,16 @@ const links = [
|
||||
|
||||
<template v-for="i in links" :key="i.label">
|
||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
||||
@click="i.action && i.action($event)"
|
||||
:class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
|
||||
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
|
||||
)">
|
||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || i.isActive?.value" />
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<ClientOnly>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
</ClientOnly>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||
</template>
|
||||
|
||||
@@ -1,103 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
|
||||
const uiState = useUIState();
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
const isVisible = computed(() => {
|
||||
// Show if there are items AND we are NOT on the upload page
|
||||
return items.value.length > 0 && route.path !== '/upload';
|
||||
});
|
||||
const isVisible = computed(() => items.value.length > 0);
|
||||
|
||||
const progress = computed(() => {
|
||||
const overallProgress = computed(() => {
|
||||
if (items.value.length === 0) return 0;
|
||||
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(totalProgress / items.value.length);
|
||||
const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(total / items.value.length);
|
||||
});
|
||||
|
||||
const isUploading = computed(() => {
|
||||
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
|
||||
const isUploading = computed(() =>
|
||||
items.value.some(i => i.status === 'uploading' || i.status === 'fetching' || i.status === 'processing')
|
||||
);
|
||||
|
||||
const isAllDone = computed(() =>
|
||||
items.value.length > 0 && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isAllDone.value) return 'All done';
|
||||
if (isUploading.value) {
|
||||
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
|
||||
return `Uploading ${count} file${count !== 1 ? 's' : ''}...`;
|
||||
}
|
||||
if (pendingCount.value > 0) return `${pendingCount.value} file${pendingCount.value !== 1 ? 's' : ''} waiting`;
|
||||
return 'Processing...';
|
||||
});
|
||||
const isDoneWithErrors = computed(() =>
|
||||
isAllDone.value &&
|
||||
items.value.some(i => i.status === 'error') && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||
);
|
||||
const doneUpload = () => {
|
||||
router.push({ name: 'videos', query: { uploaded: 'true' } });
|
||||
removeAll();
|
||||
}
|
||||
watch(isAllDone, (newItems) => {
|
||||
if (newItems && items.value.every(i => i.status === 'complete')) {
|
||||
const timeout = setTimeout(() => {
|
||||
doneUpload();
|
||||
clearTimeout(timeout);
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
|
||||
const toggleOpen = () => {
|
||||
isOpen.value = !isOpen.value;
|
||||
};
|
||||
|
||||
const goToUploadPage = () => {
|
||||
router.push('/upload');
|
||||
isOpen.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 translate-y-4"
|
||||
enter-to-class="opacity-100 translate-y-0" leave-active-class="transition-all duration-200 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
|
||||
|
||||
<!-- Mini Queue Popover -->
|
||||
<Transition enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0 scale-100"
|
||||
leave-to-class="opacity-0 translate-y-2 scale-95">
|
||||
<div v-if="isOpen"
|
||||
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 mb-2 w-80 max-h-[60vh] flex flex-col">
|
||||
<div class="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
|
||||
<h3 class="font-bold text-slate-800">Uploads</h3>
|
||||
<button @click="goToUploadPage" class="text-xs font-bold text-accent hover:underline">
|
||||
View All
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="isVisible"
|
||||
class="fixed bottom-6 right-6 z-50 w-96 rounded-2xl bg-white shadow-[0_8px_40px_rgba(0,0,0,0.16)] border border-slate-100 overflow-hidden flex flex-col"
|
||||
style="max-height: 540px;">
|
||||
|
||||
<div
|
||||
class="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true"
|
||||
class="border-b border-slate-100 last:border-0 !rounded-none" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<!-- Header bar -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
|
||||
@click="isCollapsed = !isCollapsed">
|
||||
|
||||
<!-- Floating Button -->
|
||||
<button @click="toggleOpen"
|
||||
class="relative flex items-center gap-3 bg-white pl-4 pr-5 py-3 rounded-full shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-slate-100 hover:-translate-y-1 transition-all duration-300 group">
|
||||
<!-- Progress Ring -->
|
||||
<div class="relative w-10 h-10 flex items-center justify-center">
|
||||
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
|
||||
<path class="stroke-current" fill="none" stroke-width="3"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
|
||||
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
|
||||
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
|
||||
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||
</svg>
|
||||
|
||||
<div class="absolute inset-0 flex items-center justify-center text-accent">
|
||||
<svg v-if="!isUploading && completeCount === items.length" xmlns="http://www.w3.org/2000/svg"
|
||||
class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||
stroke-linecap="round" stroke-linejoin="round">
|
||||
<!-- Status icon -->
|
||||
<div class="relative w-6 h-6 shrink-0">
|
||||
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
|
||||
<svg v-else class="w-6 h-6 text-slate-400" 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" />
|
||||
<polyline points="12 6 12 12 16 14" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
|
||||
<p class="text-xs text-slate-400 leading-tight mt-0.5">
|
||||
{{ completeCount }} of {{ items.length }} complete
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<!-- Start upload -->
|
||||
<button v-if="pendingCount > 0 && !isUploading" @click.stop="startQueue"
|
||||
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-accent hover:bg-accent/80 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
<button v-else-if="isDoneWithErrors" @click.stop="doneUpload"
|
||||
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-green-500 hover:bg-green-500/80 text-white rounded-lg transition-all">
|
||||
View Videos
|
||||
</button>
|
||||
<!-- Clear queue -->
|
||||
<!-- Add more files -->
|
||||
<button @click.stop="uiState.uploadDialogVisible = true"
|
||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
|
||||
title="Add more files">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12h14" />
|
||||
<path d="M12 5v14" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Collapse/expand -->
|
||||
<button @click.stop="isCollapsed = !isCollapsed"
|
||||
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-transform duration-200"
|
||||
:class="{ 'rotate-180': isCollapsed }" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m18 15-6-6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-left">
|
||||
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
|
||||
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
|
||||
</div>
|
||||
<div class="text-xs text-slate-500">
|
||||
{{ completeCount }} / {{ items.length }} files
|
||||
<!-- Overall progress bar -->
|
||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
||||
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingCount"
|
||||
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm border-2 border-white">
|
||||
{{ pendingCount }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<!-- File list -->
|
||||
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
|
||||
@cancel="cancelItem($event)" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
const isMounted = ref(false);
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
// Emit event when visibility changes
|
||||
const emit = defineEmits(['change']);
|
||||
@@ -121,7 +127,7 @@ defineExpose({ toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Teleport v-if="isMounted" to="body">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
|
||||
|
||||
66
src/components/app/AppButton.vue
Normal file
66
src/components/app/AppButton.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
type Size = 'sm' | 'md';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
variant?: Variant;
|
||||
size?: Size;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
}>(), {
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
loading: false,
|
||||
disabled: false,
|
||||
type: 'button',
|
||||
});
|
||||
|
||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all press-animated select-none';
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
switch (props.size) {
|
||||
case 'sm':
|
||||
return 'px-3 py-1.5 text-sm';
|
||||
case 'md':
|
||||
default:
|
||||
return 'px-4 py-2 text-sm';
|
||||
}
|
||||
});
|
||||
|
||||
const variantClass = computed(() => {
|
||||
switch (props.variant) {
|
||||
case 'secondary':
|
||||
return 'bg-muted/50 text-foreground hover:bg-muted border border-border';
|
||||
case 'danger':
|
||||
return 'bg-danger text-white hover:bg-danger/90';
|
||||
case 'ghost':
|
||||
return 'bg-transparent text-foreground/70 hover:text-foreground hover:bg-muted/50';
|
||||
case 'primary':
|
||||
default:
|
||||
return 'bg-primary text-white hover:bg-primary/90';
|
||||
}
|
||||
});
|
||||
|
||||
const disabledClass = computed(() => (props.disabled || props.loading) ? 'opacity-60 cursor-not-allowed' : '');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
:type="type"
|
||||
:disabled="disabled || loading"
|
||||
:class="cn(baseClass, sizeClass, variantClass, disabledClass)"
|
||||
>
|
||||
<span v-if="loading" class="inline-flex items-center" aria-hidden="true">
|
||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
|
||||
</svg>
|
||||
</span>
|
||||
<slot name="icon" />
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
47
src/components/app/AppConfirmHost.vue
Normal file
47
src/components/app/AppConfirmHost.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppButton from '@/components/app/AppButton.vue';
|
||||
import AppDialog from '@/components/app/AppDialog.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import { useAppConfirm } from '@/composables/useAppConfirm';
|
||||
|
||||
const confirm = useAppConfirm();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppDialog
|
||||
:visible="confirm.visible.value"
|
||||
@update:visible="(v) => !v && confirm.close()"
|
||||
:title="confirm.header.value"
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="w-9 h-9 rounded-md bg-warning/10 flex items-center justify-center shrink-0">
|
||||
<AlertTriangleIcon class="w-5 h-5 text-warning" />
|
||||
</div>
|
||||
<p class="text-sm text-foreground/80 leading-relaxed">
|
||||
{{ confirm.message.value }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:disabled="confirm.loading.value"
|
||||
@click="confirm.reject"
|
||||
>
|
||||
{{ confirm.rejectLabel.value }}
|
||||
</AppButton>
|
||||
<AppButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
:loading="confirm.loading.value"
|
||||
@click="confirm.accept"
|
||||
>
|
||||
{{ confirm.acceptLabel.value }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
</AppDialog>
|
||||
</template>
|
||||
110
src/components/app/AppDialog.vue
Normal file
110
src/components/app/AppDialog.vue
Normal file
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
const isMounted = ref(false);
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
});
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
closable?: boolean;
|
||||
maxWidthClass?: string;
|
||||
}>(), {
|
||||
title: '',
|
||||
closable: true,
|
||||
maxWidthClass: 'max-w-lg',
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'close'): void;
|
||||
}>();
|
||||
|
||||
const close = () => {
|
||||
emit('update:visible', false);
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const onKeydown = (e: KeyboardEvent) => {
|
||||
if (!props.visible) return;
|
||||
if (!props.closable) return;
|
||||
if (e.key === 'Escape') close();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(v) => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (v) window.addEventListener('keydown', onKeydown);
|
||||
else window.removeEventListener('keydown', onKeydown);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="isMounted" to="body">
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="visible" class="fixed inset-0 z-[9999]">
|
||||
<!-- Backdrop -->
|
||||
<div
|
||||
class="absolute inset-0 bg-black/30"
|
||||
@click="closable && close()"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<!-- Panel -->
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div :class="cn('w-full bg-surface border border-border rounded-lg shadow-lg overflow-hidden', maxWidthClass)">
|
||||
<!-- Header slot -->
|
||||
<div v-if="$slots.header" class="px-5 py-4 border-b border-border">
|
||||
<slot name="header" :close="close" />
|
||||
</div>
|
||||
<!-- Default title -->
|
||||
<div v-else-if="title" class="flex items-center justify-between gap-3 px-5 py-4 border-b border-border">
|
||||
<h3 class="text-sm font-semibold text-foreground">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<button
|
||||
v-if="closable"
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/60 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="p-5">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
<!-- Footer slot -->
|
||||
<div v-if="$slots.footer" class="px-5 py-4 border-t border-border bg-muted/20">
|
||||
<slot name="footer" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
91
src/components/app/AppInput.vue
Normal file
91
src/components/app/AppInput.vue
Normal file
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
// Vue macro is available at compile time; provide a safe fallback for typecheck.
|
||||
declare const defineModelModifiers: undefined | (<T>() => T);
|
||||
|
||||
|
||||
type Props = {
|
||||
modelValue?: string | number | null;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
id?: string;
|
||||
name?: string;
|
||||
autocomplete?: string;
|
||||
inputClass?: string;
|
||||
wrapperClass?: string;
|
||||
min?: number | string;
|
||||
max?: number | string;
|
||||
step?: number | string;
|
||||
maxlength?: number;
|
||||
};
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
modelValue: '',
|
||||
type: 'text',
|
||||
placeholder: '',
|
||||
readonly: false,
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: string | number | null): void;
|
||||
(e: 'enter'): void;
|
||||
}>();
|
||||
|
||||
const modelModifiers = (typeof defineModelModifiers === 'function'
|
||||
? defineModelModifiers<{ number?: boolean }>()
|
||||
: ({} as { number?: boolean }));
|
||||
|
||||
const isNumberLike = computed(() => props.type === 'number' || !!modelModifiers.number);
|
||||
|
||||
const onInput = (e: Event) => {
|
||||
const el = e.target as HTMLInputElement;
|
||||
const raw = el.value;
|
||||
if (isNumberLike.value) {
|
||||
if (raw === '') {
|
||||
emit('update:modelValue', null);
|
||||
return;
|
||||
}
|
||||
const n = Number(raw);
|
||||
emit('update:modelValue', Number.isNaN(n) ? null : n);
|
||||
return;
|
||||
}
|
||||
emit('update:modelValue', raw);
|
||||
};
|
||||
|
||||
const onKeyup = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') emit('enter');
|
||||
};
|
||||
|
||||
const baseInputClass = 'w-full px-3 py-2 rounded-md border border-border bg-surface text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 disabled:opacity-60 disabled:cursor-not-allowed';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('relative', wrapperClass)">
|
||||
<div v-if="$slots.prefix" class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/50">
|
||||
<slot name="prefix" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
:id="id"
|
||||
:name="name"
|
||||
:type="type"
|
||||
:value="modelValue ?? ''"
|
||||
:placeholder="placeholder"
|
||||
:readonly="readonly"
|
||||
:disabled="disabled"
|
||||
:autocomplete="autocomplete"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:step="step"
|
||||
:maxlength="maxlength"
|
||||
:class="cn(baseInputClass, $slots.prefix ? 'pl-10' : '', inputClass)"
|
||||
@input="onInput"
|
||||
@keyup="onKeyup"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
21
src/components/app/AppProgressBar.vue
Normal file
21
src/components/app/AppProgressBar.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
value: number;
|
||||
class?: string;
|
||||
}>();
|
||||
|
||||
const pct = computed(() => {
|
||||
const v = Number(props.value);
|
||||
if (Number.isNaN(v)) return 0;
|
||||
return Math.min(Math.max(v, 0), 100);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('w-full bg-muted/50 rounded-full overflow-hidden', props.class)" style="height: 6px">
|
||||
<div class="bg-primary h-full rounded-full transition-all duration-300" :style="{ width: `${pct}%` }" />
|
||||
</div>
|
||||
</template>
|
||||
46
src/components/app/AppSwitch.vue
Normal file
46
src/components/app/AppSwitch.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: boolean;
|
||||
disabled?: boolean;
|
||||
ariaLabel?: string;
|
||||
}>(), {
|
||||
disabled: false,
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', value: boolean): void;
|
||||
(e: 'change', value: boolean): void;
|
||||
}>();
|
||||
|
||||
const toggle = () => {
|
||||
if (props.disabled) return;
|
||||
const next = !props.modelValue;
|
||||
emit('update:modelValue', next);
|
||||
emit('change', next);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="ariaLabel"
|
||||
:disabled="disabled"
|
||||
@click="toggle"
|
||||
:class="cn(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
||||
modelValue ? 'bg-primary' : 'bg-border'
|
||||
)"
|
||||
>
|
||||
<span
|
||||
:class="cn(
|
||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
|
||||
modelValue ? 'translate-x-5' : 'translate-x-1'
|
||||
)"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
101
src/components/app/AppToastHost.vue
Normal file
101
src/components/app/AppToastHost.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
|
||||
import InfoIcon from '@/components/icons/InfoIcon.vue';
|
||||
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
|
||||
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { onBeforeUnmount, watchEffect } from 'vue';
|
||||
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
|
||||
|
||||
const { toasts, remove } = useAppToast();
|
||||
|
||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const dismiss = (id: string) => {
|
||||
const timer = timers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timers.delete(id);
|
||||
}
|
||||
remove(id);
|
||||
};
|
||||
|
||||
const iconFor = (severity: AppToastSeverity) => {
|
||||
switch (severity) {
|
||||
case 'success':
|
||||
return CheckCircleIcon;
|
||||
case 'warn':
|
||||
return AlertTriangleIcon;
|
||||
case 'error':
|
||||
return XCircleIcon;
|
||||
case 'info':
|
||||
default:
|
||||
return InfoIcon;
|
||||
}
|
||||
};
|
||||
|
||||
const toneClass = (severity: AppToastSeverity) => {
|
||||
switch (severity) {
|
||||
case 'success':
|
||||
return 'border-success/25 bg-success/5';
|
||||
case 'warn':
|
||||
return 'border-warning/25 bg-warning/5';
|
||||
case 'error':
|
||||
return 'border-danger/25 bg-danger/5';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-info/25 bg-info/5';
|
||||
}
|
||||
};
|
||||
|
||||
watchEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
for (const t of toasts.value) {
|
||||
if (timers.has(t.id)) continue;
|
||||
const life = Math.max(0, t.life ?? 3000);
|
||||
const timer = setTimeout(() => {
|
||||
dismiss(t.id);
|
||||
}, life);
|
||||
timers.set(t.id, timer);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const timer of timers.values()) clearTimeout(timer);
|
||||
timers.clear();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 w-[360px] max-w-[calc(100vw-2rem)]">
|
||||
<TransitionGroup
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 translate-y-1"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0 translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-for="t in toasts"
|
||||
:key="t.id"
|
||||
:class="cn('flex items-start gap-3 p-3 rounded-lg border shadow-sm', toneClass(t.severity))"
|
||||
>
|
||||
<component :is="iconFor(t.severity)" class="w-5 h-5 mt-0.5" :class="t.severity === 'success' ? 'text-success' : t.severity === 'warn' ? 'text-warning' : t.severity === 'error' ? 'text-danger' : 'text-info'" />
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-semibold text-foreground truncate">{{ t.summary }}</p>
|
||||
<p v-if="t.detail" class="text-xs text-foreground/70 mt-0.5 break-words">{{ t.detail }}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
|
||||
@click="dismiss(t.id)"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<XIcon class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
12
src/components/icons/ActivityIcon.vue
Normal file
12
src/components/icons/ActivityIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="m19 9-5 5-4-4-3 3" />
|
||||
</svg>
|
||||
</template>
|
||||
9
src/components/icons/AdvertisementIcon.vue
Normal file
9
src/components/icons/AdvertisementIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
13
src/components/icons/AlertTriangle.vue
Normal file
13
src/components/icons/AlertTriangle.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
|
||||
<path d="M12 9v4" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
|
||||
12
src/components/icons/BellIcon.vue
Normal file
12
src/components/icons/BellIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,3 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 532 532"><path d="M10 266c0 37 21 69 51 85-10 33-2 70 24 96s63 34 96 24c16 30 48 51 85 51s69-21 85-51c33 10 70 2 96-24s34-63 24-96c30-16 51-48 51-85s-21-69-51-85c10-33 2-70-24-96s-63-34-96-24c-16-30-48-51-85-51s-69 21-85 51c-33-10-70-2-96 24s-34 63-24 96c-30 16-51 48-51 85zm152 42c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57z" fill="#a6acb9"/><path d="M339 166c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145z" fill="#1e3050"/></svg>
|
||||
<svg 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">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/CoinsIcon.vue
Normal file
13
src/components/icons/CoinsIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<circle cx="12" cy="12" r="9"/>
|
||||
<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>
|
||||
13
src/components/icons/DownloadIcon.vue
Normal file
13
src/components/icons/DownloadIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" x2="12" y1="15" y2="3"/>
|
||||
</svg>
|
||||
</template>
|
||||
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512" fill="currentColor">
|
||||
<path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
23
src/components/icons/FileUploadType.vue
Normal file
23
src/components/icons/FileUploadType.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<!-- Local file icon -->
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532">
|
||||
<path
|
||||
d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<!-- Remote link icon -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564">
|
||||
<path
|
||||
d="M90 258h104c2-1 5-2 8-2 3-117 56-193 99-228C185 42 94 139 90 258zm128-7c28-8 73-22 135-40-5 16-9 31-14 47h103c-3-132-72-209-112-231-39 22-107 96-112 224zm51 247c10 3 21 5 32 6-9-7-18-16-27-26-2 7-3 13-5 20zm11-38c17 22 36 37 50 45 40-22 109-99 112-231H334l-6 21c-16 55-32 110-48 164zm0 0zm79-432c44 35 97 112 99 230h112c-4-119-95-216-211-230zm0 476c116-14 207-111 211-230H458c-2 117-55 195-99 230z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
9
src/components/icons/Globe.vue
Normal file
9
src/components/icons/Globe.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="524" height="524" viewBox="-10 -242 524 524"><path d="M252-232C113-232 0-119 0 20s113 252 252 252S504 159 504 20 391-232 252-232zM37 2c7-92 73-168 161-191-42 55-68 122-71 191H37zm0 36h89c4 69 30 136 71 191-87-23-153-98-160-191zm213 198c-50-52-83-125-87-198h179c-5 73-37 146-88 198h-4zM378 38h89c-7 92-73 168-161 191 42-55 68-122 71-191zm0 0zm0-36c-4-69-30-136-71-191 87 23 153 99 160 191h-89zM254-196c51 53 83 125 87 198H163c4-73 36-145 87-198h4z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
14
src/components/icons/GlobeIcon.vue
Normal file
14
src/components/icons/GlobeIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<svg v-else 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" class="w-6 h-6">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
11
src/components/icons/HeartIcon.vue
Normal file
11
src/components/icons/HeartIcon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/ImageIcon.vue
Normal file
13
src/components/icons/ImageIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/LayoutDashboard.vue
Normal file
13
src/components/icons/LayoutDashboard.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<line x1="3" x2="21" y1="9" y2="9" />
|
||||
<line x1="9" x2="9" y1="21" y2="9" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
|
||||
12
src/components/icons/LockIcon.vue
Normal file
12
src/components/icons/LockIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</template>
|
||||
12
src/components/icons/MailIcon.vue
Normal file
12
src/components/icons/MailIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<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>
|
||||
13
src/components/icons/MonitorIcon.vue
Normal file
13
src/components/icons/MonitorIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
15
src/components/icons/PencilIcon.vue
Normal file
15
src/components/icons/PencilIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
|
||||
</svg>
|
||||
<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">
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
11
src/components/icons/PlayIcon.vue
Normal file
11
src/components/icons/PlayIcon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</template>
|
||||
12
src/components/icons/PlusIcon.vue
Normal file
12
src/components/icons/PlusIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/PlusSquareIcon.vue
Normal file
13
src/components/icons/PlusSquareIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/RepeatIcon.vue
Normal file
14
src/components/icons/RepeatIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 21h5v-5" />
|
||||
</svg>
|
||||
</template>
|
||||
12
src/components/icons/SendIcon.vue
Normal file
12
src/components/icons/SendIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,9 +1,5 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path
|
||||
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L5.09 8.87a.484.484 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.48.48 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.21.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
|
||||
</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="#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-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">
|
||||
<path
|
||||
|
||||
15
src/components/icons/SlidersIcon.vue
Normal file
15
src/components/icons/SlidersIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<circle cx="8" cy="10" r="2" />
|
||||
<path d="M16 10h.01" />
|
||||
<path d="M12 10h.01" />
|
||||
<path d="M2 14h20" />
|
||||
</svg>
|
||||
</template>
|
||||
9
src/components/icons/TelegramIcon.vue
Normal file
9
src/components/icons/TelegramIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="516" height="516" viewBox="-2 -250 516 516"><path d="M256-240C119-240 8-129 8 8s111 248 248 248S504 145 504 8 393-240 256-240zM371-71c-4 39-20 134-28 178-4 19-10 25-17 25-14 2-25-9-39-18-22-15-34-23-56-37-24-17-8-25 6-40 3-4 67-61 68-67 0 0 0-3-1-4-2-1-4-1-5-1-2 1-37 24-105 70-10 6-19 10-27 9-9 0-26-5-38-9-16-5-28-7-27-16 0-4 7-9 18-14 73-31 121-52 145-62 69-29 83-34 92-34 2 0 7 1 10 3 2 2 3 4 3 7 1 3 1 6 1 10z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
13
src/components/icons/UploadIcon.vue
Normal file
13
src/components/icons/UploadIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<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>
|
||||
</template>
|
||||
10
src/components/icons/UserIcon.vue
Normal file
10
src/components/icons/UserIcon.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="518" height="532" viewBox="-3 -258 518 532"><path d="M368 120h-33l-22-64H199l-21 64h-34l32-96h160l32 96zM256-8c-35 0-64-29-64-64s29-64 64-64c36 0 64 29 64 64S292-8 256-8zm0-96c-17 0-32 14-32 32s15 32 32 32c18 0 32-14 32-32s-14-32-32-32zm0 368-12-5C92 193 7 26 17-135l1-20 238-93 239 93 1 20c9 161-76 328-227 394l-13 5zM49-133c-7 147 67 302 207 362 140-60 215-215 208-362l-208-81-207 81z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
9
src/components/icons/VideoPlayIcon.vue
Normal file
9
src/components/icons/VideoPlayIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M42 170h241c-40 35-65 87-65 144 0 17 2 33 6 48H74c-18 0-32-14-32-32V170z" fill="#a6acb9"/><path d="M458 42H345l-96 96h84c-18 8-35 19-50 32H42v160c0 18 14 32 32 32h150c3 11 7 22 11 32H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3V74c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM43 138l96-96H74c-18 0-32 14-32 32v64h1zm46 0h114l96-96H185l-96 96zm321 288c62 0 112-50 112-112s-50-112-112-112-112 50-112 112 50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144 64-144 144-144zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V258c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="564" height="468" viewBox="22 -194 564 468"><path d="M480-152H367l-96 96h84c-18 8-35 19-50 32H64v160c0 18 14 32 32 32h150c3 11 7 22 11 32H96c-35 0-64-29-64-64v-256c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3v-64c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM65-56l96-96H96c-18 0-32 14-32 32v64h1zm46 0h114l96-96H207l-96 96zm321 288c62 0 112-50 112-112S494 8 432 8 320 58 320 120s50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144S352-24 432-24zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V64c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
14
src/components/icons/VolumeIcon.vue
Normal file
14
src/components/icons/VolumeIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<line x1="4" x2="20" y1="21" y2="21" />
|
||||
<polygon points="12 11 4 18 4 6 12 11" />
|
||||
<path d="M16 8.73a2 2 0 0 1 0 3.55" />
|
||||
<path d="M18 5.05a6 6 0 0 1 0 10.9" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/VolumeOffIcon.vue
Normal file
13
src/components/icons/VolumeOffIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/WifiIcon.vue
Normal file
14
src/components/icons/WifiIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<line x1="12" x2="12.01" y1="20" y2="20" />
|
||||
</svg>
|
||||
</template>
|
||||
12
src/components/icons/XIcon.vue
Normal file
12
src/components/icons/XIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg 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">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</template>
|
||||
86
src/composables/useAppConfirm.ts
Normal file
86
src/composables/useAppConfirm.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { computed, reactive, readonly } from 'vue';
|
||||
|
||||
export type AppConfirmOptions = {
|
||||
message: string;
|
||||
header?: string;
|
||||
acceptLabel?: string;
|
||||
rejectLabel?: string;
|
||||
accept?: () => void | Promise<void>;
|
||||
reject?: () => void;
|
||||
};
|
||||
|
||||
type AppConfirmState = {
|
||||
visible: boolean;
|
||||
loading: boolean;
|
||||
message: string;
|
||||
header: string;
|
||||
acceptLabel: string;
|
||||
rejectLabel: string;
|
||||
accept?: () => void | Promise<void>;
|
||||
reject?: () => void;
|
||||
};
|
||||
|
||||
const state = reactive<AppConfirmState>({
|
||||
visible: false,
|
||||
loading: false,
|
||||
message: '',
|
||||
header: 'Confirm',
|
||||
acceptLabel: 'OK',
|
||||
rejectLabel: 'Cancel',
|
||||
});
|
||||
|
||||
const requireConfirm = (options: AppConfirmOptions) => {
|
||||
state.visible = true;
|
||||
state.loading = false;
|
||||
state.message = options.message;
|
||||
state.header = options.header ?? 'Confirm';
|
||||
state.acceptLabel = options.acceptLabel ?? 'OK';
|
||||
state.rejectLabel = options.rejectLabel ?? 'Cancel';
|
||||
state.accept = options.accept;
|
||||
state.reject = options.reject;
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
state.visible = false;
|
||||
state.loading = false;
|
||||
state.message = '';
|
||||
state.accept = undefined;
|
||||
state.reject = undefined;
|
||||
};
|
||||
|
||||
const onReject = () => {
|
||||
try {
|
||||
state.reject?.();
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
const onAccept = async () => {
|
||||
state.loading = true;
|
||||
try {
|
||||
await state.accept?.();
|
||||
close();
|
||||
} catch (e) {
|
||||
// Keep dialog open on error; caller can show a toast.
|
||||
throw e;
|
||||
} finally {
|
||||
state.loading = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const useAppConfirm = () => {
|
||||
return {
|
||||
require: requireConfirm,
|
||||
close,
|
||||
accept: onAccept,
|
||||
reject: onReject,
|
||||
visible: computed(() => state.visible),
|
||||
loading: computed(() => state.loading),
|
||||
message: computed(() => state.message),
|
||||
header: computed(() => state.header),
|
||||
acceptLabel: computed(() => state.acceptLabel),
|
||||
rejectLabel: computed(() => state.rejectLabel),
|
||||
_state: readonly(state),
|
||||
};
|
||||
};
|
||||
64
src/composables/useAppToast.ts
Normal file
64
src/composables/useAppToast.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { computed, reactive, readonly } from 'vue';
|
||||
|
||||
export type AppToastSeverity = 'success' | 'info' | 'warn' | 'warning' | 'error' | 'danger';
|
||||
|
||||
export type AppToastInput = {
|
||||
severity?: AppToastSeverity;
|
||||
summary?: string;
|
||||
detail?: string;
|
||||
life?: number; // ms
|
||||
};
|
||||
|
||||
export type AppToast = {
|
||||
id: string;
|
||||
severity: AppToastSeverity;
|
||||
summary: string;
|
||||
detail?: string;
|
||||
createdAt: number;
|
||||
life: number;
|
||||
};
|
||||
|
||||
const state = reactive<{ toasts: AppToast[] }>({
|
||||
toasts: [],
|
||||
});
|
||||
|
||||
const normalizeSeverity = (severity?: AppToastSeverity): AppToastSeverity => {
|
||||
if (!severity) return 'info';
|
||||
if (severity === 'warning') return 'warn';
|
||||
if (severity === 'danger') return 'error';
|
||||
return severity;
|
||||
};
|
||||
|
||||
const genId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const add = (input: AppToastInput) => {
|
||||
const toast: AppToast = {
|
||||
id: genId(),
|
||||
severity: normalizeSeverity(input.severity),
|
||||
summary: input.summary ?? '',
|
||||
detail: input.detail,
|
||||
createdAt: Date.now(),
|
||||
life: typeof input.life === 'number' ? input.life : 3000,
|
||||
};
|
||||
state.toasts.push(toast);
|
||||
return toast.id;
|
||||
};
|
||||
|
||||
const remove = (id: string) => {
|
||||
const idx = state.toasts.findIndex(t => t.id === id);
|
||||
if (idx !== -1) state.toasts.splice(idx, 1);
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
state.toasts.splice(0, state.toasts.length);
|
||||
};
|
||||
|
||||
export const useAppToast = () => {
|
||||
return {
|
||||
add,
|
||||
remove,
|
||||
clear,
|
||||
toasts: computed(() => state.toasts),
|
||||
_state: readonly(state),
|
||||
};
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export interface QueueItem {
|
||||
id: string;
|
||||
@@ -12,60 +12,117 @@ export interface QueueItem {
|
||||
thumbnail?: string;
|
||||
file?: File; // Keep reference to file for local uploads
|
||||
url?: string; // Keep reference to url for remote uploads
|
||||
// Upload chunk tracking
|
||||
activeChunks?: number;
|
||||
uploadedUrls?: string[];
|
||||
cancelled?: boolean;
|
||||
}
|
||||
|
||||
const items = ref<QueueItem[]>([]);
|
||||
|
||||
// Upload limits
|
||||
const MAX_ITEMS = 5;
|
||||
|
||||
// Chunk upload configuration
|
||||
const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk
|
||||
const MAX_PARALLEL = 3;
|
||||
const MAX_RETRY = 3;
|
||||
|
||||
// Track active XHRs per item id so we can abort them on cancel
|
||||
const activeXhrs = new Map<string, Set<XMLHttpRequest>>();
|
||||
|
||||
const abortItem = (id: string) => {
|
||||
const xhrs = activeXhrs.get(id);
|
||||
if (xhrs) {
|
||||
xhrs.forEach(xhr => xhr.abort());
|
||||
activeXhrs.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
export function useUploadQueue() {
|
||||
|
||||
|
||||
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
|
||||
|
||||
const addFiles = (files: FileList) => {
|
||||
const newItems: QueueItem[] = Array.from(files).map((file) => ({
|
||||
const allowed = Array.from(files).slice(0, remainingSlots.value);
|
||||
const duplicates: File[] = [];
|
||||
const fresh: File[] = [];
|
||||
|
||||
for (const file of allowed) {
|
||||
const isDupe = items.value.some(
|
||||
item => item.type === 'local' && item.name === file.name && item.file?.size === file.size
|
||||
);
|
||||
if (isDupe) duplicates.push(file);
|
||||
else fresh.push(file);
|
||||
}
|
||||
|
||||
const newItems: QueueItem[] = fresh.map((file) => ({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: file.name,
|
||||
type: 'local',
|
||||
status: 'pending', // Start as pending
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
uploaded: '0 MB',
|
||||
total: formatSize(file.size),
|
||||
speed: '0 MB/s',
|
||||
file: file,
|
||||
thumbnail: undefined // We could generate a thumbnail here if needed
|
||||
thumbnail: undefined,
|
||||
activeChunks: 0,
|
||||
uploadedUrls: [],
|
||||
cancelled: false
|
||||
}));
|
||||
|
||||
items.value.push(...newItems);
|
||||
return { added: newItems.length, skipped: files.length - allowed.length, duplicates: duplicates.length };
|
||||
};
|
||||
|
||||
const addRemoteUrls = (urls: string[]) => {
|
||||
const newItems: QueueItem[] = urls.map((url) => ({
|
||||
const allowed = urls.slice(0, remainingSlots.value);
|
||||
const fresh = allowed.filter(url => !items.value.some(item => item.type === 'remote' && item.url === url));
|
||||
const duplicateCount = allowed.length - fresh.length;
|
||||
const newItems: QueueItem[] = fresh.map((url) => ({
|
||||
id: Math.random().toString(36).substring(2, 9),
|
||||
name: url.split('/').pop() || 'Remote File',
|
||||
type: 'remote',
|
||||
status: 'fetching', // Remote URLs start fetching immediately or pending? User said "khi nao nhan upload". Let's use pending.
|
||||
status: 'pending',
|
||||
progress: 0,
|
||||
uploaded: '0 MB',
|
||||
total: 'Unknown',
|
||||
speed: '0 MB/s',
|
||||
url: url
|
||||
url: url,
|
||||
activeChunks: 0,
|
||||
uploadedUrls: [],
|
||||
cancelled: false
|
||||
}));
|
||||
|
||||
// Override status to pending for consistency with user request
|
||||
newItems.forEach(i => i.status = 'pending');
|
||||
|
||||
items.value.push(...newItems);
|
||||
return { added: newItems.length, skipped: urls.length - allowed.length, duplicates: duplicateCount };
|
||||
};
|
||||
|
||||
const removeItem = (id: string) => {
|
||||
abortItem(id);
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (item) item.cancelled = true;
|
||||
const index = items.value.findIndex(item => item.id === id);
|
||||
if (index !== -1) {
|
||||
items.value.splice(index, 1);
|
||||
if (index !== -1) items.value.splice(index, 1);
|
||||
};
|
||||
|
||||
const cancelItem = (id: string) => {
|
||||
abortItem(id);
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (item) {
|
||||
item.cancelled = true;
|
||||
item.status = 'error';
|
||||
item.activeChunks = 0;
|
||||
item.speed = '0 MB/s';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const startQueue = () => {
|
||||
items.value.forEach(item => {
|
||||
if (item.status === 'pending') {
|
||||
if (item.type === 'local') {
|
||||
startMockUpload(item.id);
|
||||
startChunkUpload(item.id);
|
||||
} else {
|
||||
startMockRemoteFetch(item.id);
|
||||
}
|
||||
@@ -73,57 +130,195 @@ export function useUploadQueue() {
|
||||
});
|
||||
};
|
||||
|
||||
// Mock Upload Logic
|
||||
const startMockUpload = (id: string) => {
|
||||
// Real Chunk Upload Logic
|
||||
const startChunkUpload = async (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
if (!item || !item.file) return;
|
||||
|
||||
item.status = 'uploading';
|
||||
let progress = 0;
|
||||
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown
|
||||
|
||||
// Random speed between 1MB/s and 5MB/s
|
||||
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4);
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (progress >= 100) {
|
||||
clearInterval(interval);
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
return;
|
||||
item.activeChunks = 0;
|
||||
item.uploadedUrls = [];
|
||||
|
||||
const file = item.file;
|
||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
||||
const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
|
||||
const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i);
|
||||
|
||||
const updateProgress = () => {
|
||||
let totalUploaded = 0;
|
||||
progressMap.forEach(value => {
|
||||
totalUploaded += value;
|
||||
});
|
||||
const percent = Math.min((totalUploaded / file.size) * 100, 100);
|
||||
item.progress = parseFloat(percent.toFixed(1));
|
||||
item.uploaded = formatSize(totalUploaded);
|
||||
|
||||
// Calculate speed (simplified)
|
||||
const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0;
|
||||
item.speed = formatSize(currentSpeed) + '/s';
|
||||
};
|
||||
|
||||
const processQueue = async () => {
|
||||
if (item.cancelled) return;
|
||||
|
||||
const activePromises: Promise<void>[] = [];
|
||||
|
||||
while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) {
|
||||
const index = queue.shift()!;
|
||||
item.activeChunks = (item.activeChunks || 0) + 1;
|
||||
|
||||
const promise = uploadChunk(index, file, progressMap, updateProgress, item)
|
||||
.then(() => {
|
||||
item.activeChunks = (item.activeChunks || 0) - 1;
|
||||
});
|
||||
activePromises.push(promise);
|
||||
}
|
||||
|
||||
// Increment progress randomly
|
||||
const increment = Math.random() * 5 + 1; // 1-6% increment
|
||||
progress = Math.min(progress + increment, 100);
|
||||
|
||||
item.progress = Math.floor(progress);
|
||||
|
||||
// Calculate uploaded size string
|
||||
const currentBytes = (progress / 100) * totalSize;
|
||||
item.uploaded = formatSize(currentBytes);
|
||||
|
||||
// Re-randomize speed for realism
|
||||
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
|
||||
item.speed = formatSize(currentSpeed) + '/s';
|
||||
if (activePromises.length > 0) {
|
||||
await Promise.all(activePromises);
|
||||
await processQueue();
|
||||
}
|
||||
};
|
||||
|
||||
}, 500);
|
||||
try {
|
||||
await processQueue();
|
||||
|
||||
if (!item.cancelled) {
|
||||
item.status = 'processing';
|
||||
await completeUpload(item);
|
||||
}
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
console.error('Upload failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const uploadChunk = (
|
||||
index: number,
|
||||
file: File,
|
||||
progressMap: Map<number, number>,
|
||||
updateProgress: () => void,
|
||||
item: QueueItem
|
||||
): Promise<void> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let retry = 0;
|
||||
|
||||
const attempt = () => {
|
||||
if (item.cancelled) return resolve();
|
||||
|
||||
const start = index * CHUNK_SIZE;
|
||||
const end = Math.min(start + CHUNK_SIZE, file.size);
|
||||
const chunk = file.slice(start, end);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', chunk, file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
|
||||
|
||||
// Register this XHR so it can be aborted on cancel
|
||||
if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set());
|
||||
activeXhrs.get(item.id)!.add(xhr);
|
||||
|
||||
const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
|
||||
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
progressMap.set(index, e.loaded);
|
||||
updateProgress();
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
unregister();
|
||||
if (item.cancelled) return resolve();
|
||||
if (xhr.status === 200) {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText);
|
||||
if (res.status === 'success') {
|
||||
progressMap.set(index, chunk.size);
|
||||
if (item.uploadedUrls) {
|
||||
item.uploadedUrls[index] = res.data.url;
|
||||
}
|
||||
updateProgress();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
handleError();
|
||||
}
|
||||
}
|
||||
handleError();
|
||||
};
|
||||
|
||||
xhr.onabort = () => {
|
||||
unregister();
|
||||
resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
unregister();
|
||||
handleError();
|
||||
};
|
||||
|
||||
function handleError() {
|
||||
retry++;
|
||||
if (retry <= MAX_RETRY) {
|
||||
setTimeout(attempt, 2000);
|
||||
} else {
|
||||
item.status = 'error';
|
||||
reject(new Error(`Failed to upload chunk ${index + 1}`));
|
||||
}
|
||||
}
|
||||
|
||||
xhr.send(formData);
|
||||
};
|
||||
|
||||
attempt();
|
||||
});
|
||||
};
|
||||
|
||||
const completeUpload = async (item: QueueItem) => {
|
||||
if (!item.file || !item.uploadedUrls) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
filename: item.file.name,
|
||||
chunks: item.uploadedUrls,
|
||||
size: item.file.size
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Merge failed');
|
||||
}
|
||||
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
item.uploaded = item.total;
|
||||
item.speed = '0 MB/s';
|
||||
} catch (error) {
|
||||
item.status = 'error';
|
||||
console.error('Merge failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Mock Remote Fetch Logic
|
||||
const startMockRemoteFetch = (id: string) => {
|
||||
const item = items.value.find(i => i.id === id);
|
||||
const item = items.value.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
item.status = 'fetching'; // Update status to fetching
|
||||
|
||||
// Remote fetch takes some time then completes
|
||||
setTimeout(() => {
|
||||
// Switch to uploading/processing phase if we wanted, or just finish
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
}, 3000 + Math.random() * 3000);
|
||||
item.status = 'fetching';
|
||||
|
||||
setTimeout(() => {
|
||||
item.status = 'complete';
|
||||
item.progress = 100;
|
||||
}, 3000 + Math.random() * 3000);
|
||||
};
|
||||
|
||||
|
||||
@@ -150,15 +345,29 @@ export function useUploadQueue() {
|
||||
const pendingCount = computed(() => {
|
||||
return items.value.filter(i => i.status === 'pending').length;
|
||||
});
|
||||
|
||||
function removeAll() {
|
||||
items.value = [];
|
||||
}
|
||||
// watch(items, (newItems) => {
|
||||
// // console.log(newItems);
|
||||
// if (newItems.length === 0) return;
|
||||
// if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) {
|
||||
// // startQueue();
|
||||
// items.value = [];
|
||||
// }
|
||||
// }, { deep: true });
|
||||
return {
|
||||
items,
|
||||
addFiles,
|
||||
addRemoteUrls,
|
||||
removeItem,
|
||||
cancelItem,
|
||||
removeAll,
|
||||
startQueue,
|
||||
totalSize,
|
||||
completeCount,
|
||||
pendingCount
|
||||
pendingCount,
|
||||
remainingSlots,
|
||||
maxItems: MAX_ITEMS,
|
||||
};
|
||||
}
|
||||
|
||||
125
src/index.tsx
125
src/index.tsx
@@ -1,111 +1,26 @@
|
||||
import { renderSSRHead } from '@unhead/vue/server';
|
||||
import { Hono } from 'hono';
|
||||
import { contextStorage } from 'hono/context-storage';
|
||||
import { cors } from "hono/cors";
|
||||
import { streamText } from 'hono/streaming';
|
||||
import isMobile from 'is-mobile';
|
||||
import { renderToWebStream } from 'vue/server-renderer';
|
||||
import { buildBootstrapScript } from './lib/manifest';
|
||||
import { styleTags } from './lib/primePassthrough';
|
||||
import { createApp } from './main';
|
||||
import { useAuthStore } from './stores/auth';
|
||||
// @ts-ignore
|
||||
import Base from '@primevue/core/base';
|
||||
import { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||
const app = new Hono()
|
||||
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
|
||||
// app.use(renderer)
|
||||
app.use('*', contextStorage());
|
||||
app.use(cors(), async (c, next) => {
|
||||
c.set("fetch", app.request.bind(app));
|
||||
const ua = c.req.header("User-Agent")
|
||||
if (!ua) {
|
||||
return c.json({ error: "User-Agent header is missing" }, 400);
|
||||
};
|
||||
c.set("isMobile", isMobile({ ua }));
|
||||
await next();
|
||||
}, async (c, next) => {
|
||||
const path = c.req.path
|
||||
|
||||
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||
return await next()
|
||||
}
|
||||
const url = new URL(c.req.url)
|
||||
url.host = 'api.pipic.fun'
|
||||
url.protocol = 'https:'
|
||||
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||
url.port = ''
|
||||
// console.log("url", url.toString())
|
||||
// console.log("c.req.raw", c.req.raw)
|
||||
const headers = new Headers(c.req.header());
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
||||
import { setupMiddlewares } from './server/middlewares/setup';
|
||||
import { registerDisplayRoutes } from './server/routes/display';
|
||||
import { registerManifestRoutes } from './server/routes/manifest';
|
||||
import { registerMergeRoutes } from './server/routes/merge';
|
||||
import { registerSSRRoutes } from './server/routes/ssr';
|
||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
||||
|
||||
return fetch(url.toString(), {
|
||||
method: c.req.method,
|
||||
headers: headers,
|
||||
body: c.req.raw.body,
|
||||
// @ts-ignore
|
||||
duplex: 'half',
|
||||
credentials: 'include'
|
||||
});
|
||||
});
|
||||
app.get("/.well-known/*", (c) => {
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
app.get("*", async (c) => {
|
||||
const nonce = crypto.randomUUID();
|
||||
const url = new URL(c.req.url);
|
||||
const { app, router, head, pinia, bodyClass } = createApp();
|
||||
app.provide("honoContext", c);
|
||||
const auth = useAuthStore();
|
||||
auth.$reset();
|
||||
// auth.initialized = false;
|
||||
await auth.init();
|
||||
await router.push(url.pathname);
|
||||
await router.isReady();
|
||||
let usedStyles = new Set<String>();
|
||||
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name)
|
||||
return streamText(c, async (stream) => {
|
||||
c.header("Content-Type", "text/html; charset=utf-8");
|
||||
c.header("Content-Encoding", "Identity");
|
||||
const ctx: Record<string, any> = {};
|
||||
const appStream = renderToWebStream(app, ctx);
|
||||
// console.log("ctx: ", );
|
||||
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||
await stream.write("<base href='" + url.origin + "'/>");
|
||||
const app = new Hono();
|
||||
|
||||
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||
await stream.write(buildBootstrapScript());
|
||||
if (usedStyles.size > 0) {
|
||||
defaultNames.forEach(name => usedStyles.add(name));
|
||||
}
|
||||
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
|
||||
await stream.write(`</head><body class='${bodyClass}'>`);
|
||||
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
|
||||
delete ctx.teleports
|
||||
delete ctx.__teleportBuffers
|
||||
delete ctx.modules;
|
||||
Object.assign(ctx, { $p: pinia.state.value });
|
||||
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
|
||||
await stream.write("</body></html>");
|
||||
});
|
||||
})
|
||||
const ESCAPE_LOOKUP: { [match: string]: string } = {
|
||||
"&": "\\u0026",
|
||||
">": "\\u003e",
|
||||
"<": "\\u003c",
|
||||
"\u2028": "\\u2028",
|
||||
"\u2029": "\\u2029",
|
||||
};
|
||||
// Global middlewares
|
||||
setupMiddlewares(app);
|
||||
|
||||
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||
// API proxy middleware (handles /r/*)
|
||||
app.use(apiProxyMiddleware);
|
||||
|
||||
function htmlEscape(str: string): string {
|
||||
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||
}
|
||||
export default app
|
||||
// Routes
|
||||
registerWellKnownRoutes(app);
|
||||
registerMergeRoutes(app);
|
||||
registerDisplayRoutes(app);
|
||||
registerManifestRoutes(app);
|
||||
registerSSRRoutes(app);
|
||||
|
||||
export default app;
|
||||
|
||||
91
src/lib/PiniaSharedState.ts
Normal file
91
src/lib/PiniaSharedState.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { DefineStoreOptions, PiniaPluginContext, StateTree } from "pinia";
|
||||
|
||||
type Serializer<T extends StateTree> = {
|
||||
serialize: (value: T) => string;
|
||||
deserialize: (value: string) => T;
|
||||
};
|
||||
|
||||
interface BroadcastMessage {
|
||||
type: "STATE_UPDATE" | "SYNC_REQUEST";
|
||||
timestamp?: number;
|
||||
state?: string;
|
||||
}
|
||||
|
||||
type PluginOptions<T extends StateTree> = {
|
||||
enable?: boolean;
|
||||
initialize?: boolean;
|
||||
serializer?: Serializer<T>;
|
||||
};
|
||||
|
||||
export interface StoreOptions<
|
||||
S extends StateTree = StateTree,
|
||||
G = object,
|
||||
A = object,
|
||||
> extends DefineStoreOptions<string, S, G, A> {
|
||||
share?: PluginOptions<S>;
|
||||
}
|
||||
|
||||
// Add type extension for Pinia
|
||||
declare module "pinia" {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export interface DefineStoreOptionsBase<S, Store> {
|
||||
share?: PluginOptions<S>;
|
||||
}
|
||||
}
|
||||
|
||||
export default function PiniaSharedState<T extends StateTree>({
|
||||
enable = false,
|
||||
initialize = false,
|
||||
serializer = {
|
||||
serialize: JSON.stringify,
|
||||
deserialize: JSON.parse,
|
||||
},
|
||||
}: PluginOptions<T> = {}) {
|
||||
return ({ store, options }: PiniaPluginContext) => {
|
||||
if (!(options.share?.enable ?? enable)) return;
|
||||
const channel = new BroadcastChannel(store.$id);
|
||||
let timestamp = 0;
|
||||
let externalUpdate = false;
|
||||
|
||||
// Initial state sync
|
||||
if (options.share?.initialize ?? initialize) {
|
||||
channel.postMessage({ type: "SYNC_REQUEST" });
|
||||
}
|
||||
|
||||
// State change listener
|
||||
store.$subscribe((_mutation, state) => {
|
||||
if (externalUpdate) return;
|
||||
|
||||
timestamp = Date.now();
|
||||
channel.postMessage({
|
||||
type: "STATE_UPDATE",
|
||||
timestamp,
|
||||
state: serializer.serialize(state as T),
|
||||
});
|
||||
});
|
||||
|
||||
// Message handler
|
||||
channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
|
||||
const data = event.data;
|
||||
if (
|
||||
data.type === "STATE_UPDATE" &&
|
||||
data.timestamp &&
|
||||
data.timestamp > timestamp &&
|
||||
data.state
|
||||
) {
|
||||
externalUpdate = true;
|
||||
timestamp = data.timestamp;
|
||||
store.$patch(serializer.deserialize(data.state));
|
||||
externalUpdate = false;
|
||||
}
|
||||
|
||||
if (data.type === "SYNC_REQUEST") {
|
||||
channel.postMessage({
|
||||
type: "STATE_UPDATE",
|
||||
timestamp,
|
||||
state: serializer.serialize(store.$state as T),
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
}
|
||||
4
src/lib/interface.ts
Normal file
4
src/lib/interface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface ITinyMqttClient {
|
||||
connect(): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
120
src/lib/liteMqtt.ts
Normal file
120
src/lib/liteMqtt.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ITinyMqttClient } from "./interface";
|
||||
|
||||
export type MessageCallback = (topic: string, payload: string) => void;
|
||||
export class TinyMqttClient implements ITinyMqttClient {
|
||||
private ws: WebSocket | null = null;
|
||||
private encoder = new TextEncoder();
|
||||
private decoder = new TextDecoder();
|
||||
private worker: Worker | null = null;
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private topics: string[],
|
||||
private onMessage: MessageCallback
|
||||
) {}
|
||||
|
||||
public connect(): void {
|
||||
this.ws = new WebSocket(this.url, 'mqtt');
|
||||
this.ws.binaryType = 'arraybuffer';
|
||||
|
||||
this.ws.onopen = () => {
|
||||
this.sendConnect();
|
||||
};
|
||||
|
||||
this.ws.onmessage = (e) => this.handlePacket(new Uint8Array(e.data));
|
||||
this.ws.onclose = () => this.stopHeartbeatWorker();
|
||||
}
|
||||
public disconnect(): void {
|
||||
this.ws?.close();
|
||||
this.stopHeartbeatWorker();
|
||||
}
|
||||
private sendConnect(): void {
|
||||
const clientId = `ws_worker_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const idBytes = this.encoder.encode(clientId);
|
||||
// Keep-alive 60s
|
||||
const packet = new Uint8Array([
|
||||
0x10, 12 + idBytes.length,
|
||||
0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x04, 0x02, 0x00, 0x3c,
|
||||
0x00, idBytes.length, ...idBytes
|
||||
]);
|
||||
this.ws?.send(packet);
|
||||
}
|
||||
|
||||
private startHeartbeatWorker(): void {
|
||||
if (this.worker) return;
|
||||
|
||||
// Tạo nội dung Worker dưới dạng chuỗi
|
||||
const workerCode = `
|
||||
let timer = null;
|
||||
self.onmessage = (e) => {
|
||||
if (e.data === 'START') {
|
||||
timer = setInterval(() => self.postMessage('TICK'), 30000);
|
||||
} else if (e.data === 'STOP') {
|
||||
clearInterval(timer);
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
const blob = new Blob([workerCode], { type: 'application/javascript' });
|
||||
this.worker = new Worker(URL.createObjectURL(blob));
|
||||
|
||||
this.worker.onmessage = (e) => {
|
||||
if (e.data === 'TICK' && this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(new Uint8Array([0xC0, 0x00])); // Gửi PINGREQ
|
||||
}
|
||||
};
|
||||
|
||||
this.worker.postMessage('START');
|
||||
}
|
||||
|
||||
private stopHeartbeatWorker(): void {
|
||||
if (this.worker) {
|
||||
this.worker.postMessage('STOP');
|
||||
this.worker.terminate();
|
||||
this.worker = null;
|
||||
console.log('🛑 Worker stopped');
|
||||
}
|
||||
}
|
||||
|
||||
private handlePacket(data: Uint8Array): void {
|
||||
const type = data[0] & 0xF0;
|
||||
switch (type) {
|
||||
case 0x20: // CONNACK
|
||||
this.startHeartbeatWorker();
|
||||
this.subscribe();
|
||||
break;
|
||||
case 0xD0: // PINGRESP
|
||||
break;
|
||||
case 0x30: // PUBLISH
|
||||
this.parsePublish(data);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private subscribe(): void {
|
||||
let payload: number[] = [];
|
||||
this.topics.forEach(t => {
|
||||
const b = this.encoder.encode(t);
|
||||
payload.push(0x00, b.length, ...Array.from(b), 0x00);
|
||||
});
|
||||
const packet = new Uint8Array([0x82, 2 + payload.length, 0x00, 0x01, ...payload]);
|
||||
this.ws?.send(packet);
|
||||
}
|
||||
|
||||
private parsePublish(data: Uint8Array): void {
|
||||
const tLen = (data[2] << 8) | data[3];
|
||||
const topic = this.decoder.decode(data.slice(4, 4 + tLen));
|
||||
const payload = this.decoder.decode(data.slice(4 + tLen));
|
||||
this.onMessage(topic, payload);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Cách dùng ---
|
||||
// const client = new TinyMqttClient(
|
||||
// 'ws://your-emqx:8083',
|
||||
// ['sensor/temp', 'sensor/humi', 'system/ping'],
|
||||
// (topic, msg) => {
|
||||
// console.log(`[${topic}]: ${msg}`);
|
||||
// }
|
||||
// );
|
||||
// client.connect();
|
||||
File diff suppressed because one or more lines are too long
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
77
src/lib/swr/cache/adapters/localStorage.ts
vendored
@@ -1,77 +0,0 @@
|
||||
import SWRVCache, { type ICacheItem } from '..'
|
||||
import type { IKey } from '../../types'
|
||||
|
||||
/**
|
||||
* LocalStorage cache adapter for swrv data cache.
|
||||
* https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
|
||||
*/
|
||||
export default class LocalStorageCache extends SWRVCache<any> {
|
||||
private STORAGE_KEY
|
||||
|
||||
constructor (key = 'swrv', ttl = 0) {
|
||||
super(ttl)
|
||||
this.STORAGE_KEY = key
|
||||
}
|
||||
|
||||
private encode (storage: any) { return JSON.stringify(storage) }
|
||||
private decode (storage: any) { return JSON.parse(storage) }
|
||||
|
||||
get (k: IKey): ICacheItem<IKey> {
|
||||
const item = localStorage.getItem(this.STORAGE_KEY)
|
||||
if (item) {
|
||||
const _key = this.serializeKey(k)
|
||||
const itemParsed: ICacheItem<any> = JSON.parse(item)[_key]
|
||||
|
||||
if (itemParsed?.expiresAt === null) {
|
||||
itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
|
||||
}
|
||||
|
||||
return itemParsed
|
||||
}
|
||||
|
||||
return undefined as any
|
||||
}
|
||||
|
||||
set (k: string, v: any, ttl: number) {
|
||||
let payload = {}
|
||||
const _key = this.serializeKey(k)
|
||||
const timeToLive = ttl || this.ttl
|
||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
||||
const now = Date.now()
|
||||
const item = {
|
||||
data: v,
|
||||
createdAt: now,
|
||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
||||
}
|
||||
|
||||
if (storage) {
|
||||
payload = this.decode(storage)
|
||||
(payload as any)[_key] = item
|
||||
} else {
|
||||
payload = { [_key]: item }
|
||||
}
|
||||
|
||||
this.dispatchExpire(timeToLive, item, _key)
|
||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
||||
}
|
||||
|
||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
||||
ttl && setTimeout(() => {
|
||||
const current = Date.now()
|
||||
const hasExpired = current >= item.expiresAt
|
||||
if (hasExpired) this.delete(serializedKey)
|
||||
}, ttl)
|
||||
}
|
||||
|
||||
delete (serializedKey: string) {
|
||||
const storage = localStorage.getItem(this.STORAGE_KEY)
|
||||
let payload = {} as Record<string, any>
|
||||
|
||||
if (storage) {
|
||||
payload = this.decode(storage)
|
||||
delete payload[serializedKey]
|
||||
}
|
||||
|
||||
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
|
||||
}
|
||||
}
|
||||
72
src/lib/swr/cache/index.ts
vendored
72
src/lib/swr/cache/index.ts
vendored
@@ -1,72 +0,0 @@
|
||||
import type { IKey } from '../types'
|
||||
import hash from '../lib/hash'
|
||||
export interface ICacheItem<Data> {
|
||||
data: Data,
|
||||
createdAt: number,
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
function serializeKeyDefault (key: IKey): string {
|
||||
if (typeof key === 'function') {
|
||||
try {
|
||||
key = key()
|
||||
} catch (err) {
|
||||
// dependencies not ready
|
||||
key = ''
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(key)) {
|
||||
key = hash(key)
|
||||
} else {
|
||||
// convert null to ''
|
||||
key = String(key || '')
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
||||
export default class SWRVCache<CacheData> {
|
||||
protected ttl: number
|
||||
private items?: Map<string, ICacheItem<CacheData>>
|
||||
|
||||
constructor (ttl = 0) {
|
||||
this.items = new Map()
|
||||
this.ttl = ttl
|
||||
}
|
||||
|
||||
serializeKey (key: IKey): string {
|
||||
return serializeKeyDefault(key)
|
||||
}
|
||||
|
||||
get (k: string): ICacheItem<CacheData> {
|
||||
const _key = this.serializeKey(k)
|
||||
return this.items!.get(_key)!
|
||||
}
|
||||
|
||||
set (k: string, v: any, ttl: number) {
|
||||
const _key = this.serializeKey(k)
|
||||
const timeToLive = ttl || this.ttl
|
||||
const now = Date.now()
|
||||
const item = {
|
||||
data: v,
|
||||
createdAt: now,
|
||||
expiresAt: timeToLive ? now + timeToLive : Infinity
|
||||
}
|
||||
|
||||
this.dispatchExpire(timeToLive, item, _key)
|
||||
this.items!.set(_key, item)
|
||||
}
|
||||
|
||||
dispatchExpire (ttl: number, item: any, serializedKey: string) {
|
||||
ttl && setTimeout(() => {
|
||||
const current = Date.now()
|
||||
const hasExpired = current >= item.expiresAt
|
||||
if (hasExpired) this.delete(serializedKey)
|
||||
}, ttl)
|
||||
}
|
||||
|
||||
delete (serializedKey: string) {
|
||||
this.items!.delete(serializedKey)
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import SWRVCache from './cache'
|
||||
import useSWRV, { mutate } from './use-swrv'
|
||||
|
||||
export {
|
||||
type IConfig
|
||||
} from './types'
|
||||
export { mutate, SWRVCache }
|
||||
export default useSWRV
|
||||
@@ -1,44 +0,0 @@
|
||||
// From https://github.com/vercel/swr/blob/master/src/libs/hash.ts
|
||||
// use WeakMap to store the object->key mapping
|
||||
// so the objects can be garbage collected.
|
||||
// WeakMap uses a hashtable under the hood, so the lookup
|
||||
// complexity is almost O(1).
|
||||
const table = new WeakMap()
|
||||
|
||||
// counter of the key
|
||||
let counter = 0
|
||||
|
||||
// hashes an array of objects and returns a string
|
||||
export default function hash (args: any[]): string {
|
||||
if (!args.length) return ''
|
||||
let key = 'arg'
|
||||
for (let i = 0; i < args.length; ++i) {
|
||||
let _hash
|
||||
if (
|
||||
args[i] === null ||
|
||||
(typeof args[i] !== 'object' && typeof args[i] !== 'function')
|
||||
) {
|
||||
// need to consider the case that args[i] is a string:
|
||||
// args[i] _hash
|
||||
// "undefined" -> '"undefined"'
|
||||
// undefined -> 'undefined'
|
||||
// 123 -> '123'
|
||||
// null -> 'null'
|
||||
// "null" -> '"null"'
|
||||
if (typeof args[i] === 'string') {
|
||||
_hash = '"' + args[i] + '"'
|
||||
} else {
|
||||
_hash = String(args[i])
|
||||
}
|
||||
} else {
|
||||
if (!table.has(args[i])) {
|
||||
_hash = counter
|
||||
table.set(args[i], counter++)
|
||||
} else {
|
||||
_hash = table.get(args[i])
|
||||
}
|
||||
}
|
||||
key += '@' + _hash
|
||||
}
|
||||
return key
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
function isOnline (): boolean {
|
||||
if (typeof navigator.onLine !== 'undefined') {
|
||||
return navigator.onLine
|
||||
}
|
||||
// always assume it's online
|
||||
return true
|
||||
}
|
||||
|
||||
function isDocumentVisible (): boolean {
|
||||
if (
|
||||
typeof document !== 'undefined' &&
|
||||
typeof document.visibilityState !== 'undefined'
|
||||
) {
|
||||
return document.visibilityState !== 'hidden'
|
||||
}
|
||||
// always assume it's visible
|
||||
return true
|
||||
}
|
||||
|
||||
const fetcher = (url: string | Request) => fetch(url).then(res => res.json())
|
||||
|
||||
export default {
|
||||
isOnline,
|
||||
isDocumentVisible,
|
||||
fetcher
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { Ref, WatchSource } from 'vue'
|
||||
import SWRVCache from './cache'
|
||||
import LocalStorageCache from './cache/adapters/localStorage'
|
||||
|
||||
export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
|
||||
|
||||
export interface IConfig<
|
||||
Data = any,
|
||||
Fn extends fetcherFn<Data> = fetcherFn<Data>
|
||||
> {
|
||||
refreshInterval?: number
|
||||
cache?: LocalStorageCache | SWRVCache<any>
|
||||
dedupingInterval?: number
|
||||
ttl?: number
|
||||
serverTTL?: number
|
||||
revalidateOnFocus?: boolean
|
||||
revalidateDebounce?: number
|
||||
shouldRetryOnError?: boolean
|
||||
errorRetryInterval?: number
|
||||
errorRetryCount?: number
|
||||
fetcher?: Fn,
|
||||
isOnline?: () => boolean
|
||||
isDocumentVisible?: () => boolean
|
||||
}
|
||||
|
||||
export interface revalidateOptions {
|
||||
shouldRetryOnError?: boolean,
|
||||
errorRetryCount?: number,
|
||||
forceRevalidate?: boolean,
|
||||
}
|
||||
|
||||
export interface IResponse<Data = any, Error = any> {
|
||||
data: Ref<Data | undefined>
|
||||
error: Ref<Error | undefined>
|
||||
isValidating: Ref<boolean>
|
||||
isLoading: Ref<boolean>
|
||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => Promise<void>
|
||||
}
|
||||
|
||||
export type keyType = string | any[] | null | undefined
|
||||
|
||||
export type IKey = keyType | WatchSource<keyType>
|
||||
@@ -1,470 +0,0 @@
|
||||
/** ____
|
||||
*--------------/ \.------------------/
|
||||
* / swrv \. / //
|
||||
* / / /\. / //
|
||||
* / _____/ / \. /
|
||||
* / / ____/ . \. /
|
||||
* / \ \_____ \. /
|
||||
* / . \_____ \ \ / //
|
||||
* \ _____/ / ./ / //
|
||||
* \ / _____/ ./ /
|
||||
* \ / / . ./ /
|
||||
* \ / / ./ /
|
||||
* . \/ ./ / //
|
||||
* \ ./ / //
|
||||
* \.. / /
|
||||
* . ||| /
|
||||
* ||| /
|
||||
* . ||| / //
|
||||
* ||| / //
|
||||
* ||| /
|
||||
*/
|
||||
import { tinyassert } from "@hiogawa/utils";
|
||||
import {
|
||||
getCurrentInstance,
|
||||
inject,
|
||||
isReadonly,
|
||||
isRef,
|
||||
// isRef,
|
||||
onMounted,
|
||||
onServerPrefetch,
|
||||
onUnmounted,
|
||||
reactive,
|
||||
ref,
|
||||
toRefs,
|
||||
useSSRContext,
|
||||
watch,
|
||||
type FunctionPlugin
|
||||
} from 'vue';
|
||||
import SWRVCache from './cache';
|
||||
import webPreset from './lib/web-preset';
|
||||
import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types';
|
||||
|
||||
type StateRef<Data, Error> = {
|
||||
data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any
|
||||
};
|
||||
|
||||
const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
||||
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
|
||||
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
|
||||
|
||||
const defaultConfig: IConfig = {
|
||||
cache: DATA_CACHE,
|
||||
refreshInterval: 0,
|
||||
ttl: 0,
|
||||
serverTTL: 1000,
|
||||
dedupingInterval: 2000,
|
||||
revalidateOnFocus: true,
|
||||
revalidateDebounce: 0,
|
||||
shouldRetryOnError: true,
|
||||
errorRetryInterval: 5000,
|
||||
errorRetryCount: 5,
|
||||
fetcher: webPreset.fetcher,
|
||||
isOnline: webPreset.isOnline,
|
||||
isDocumentVisible: webPreset.isDocumentVisible
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache the refs for later revalidation
|
||||
*/
|
||||
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
|
||||
const refCacheItem = REF_CACHE.get(key)
|
||||
if (refCacheItem) {
|
||||
refCacheItem.data.push(theRef)
|
||||
} else {
|
||||
// #51 ensures ref cache does not evict too soon
|
||||
const gracePeriod = 5000
|
||||
REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl)
|
||||
}
|
||||
}
|
||||
|
||||
function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void {
|
||||
if (!(config as any).isDocumentVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount)
|
||||
const timeout = count * (config as any).errorRetryInterval
|
||||
setTimeout(() => {
|
||||
revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true })
|
||||
}, timeout)
|
||||
}
|
||||
|
||||
/**
|
||||
* Main mutation function for receiving data from promises to change state and
|
||||
* set data cache
|
||||
*/
|
||||
const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => {
|
||||
let data, error, isValidating
|
||||
|
||||
if (isPromise(res)) {
|
||||
try {
|
||||
data = await res
|
||||
} catch (err) {
|
||||
error = err
|
||||
}
|
||||
} else {
|
||||
data = res
|
||||
}
|
||||
|
||||
// eslint-disable-next-line prefer-const
|
||||
isValidating = false
|
||||
|
||||
const newData = { data, error, isValidating }
|
||||
if (typeof data !== 'undefined') {
|
||||
try {
|
||||
cache.set(key, newData, Number(ttl))
|
||||
} catch (err) {
|
||||
console.error('swrv(mutate): failed to set cache', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate all swrv instances with new data
|
||||
*/
|
||||
const stateRef = REF_CACHE.get(key)
|
||||
if (stateRef && stateRef.data.length) {
|
||||
// This filter fixes #24 race conditions to only update ref data of current
|
||||
// key, while data cache will continue to be updated if revalidation is
|
||||
// fired
|
||||
let refs = stateRef.data.filter(r => r.key === key)
|
||||
|
||||
refs.forEach((r, idx) => {
|
||||
if (typeof newData.data !== 'undefined') {
|
||||
r.data = newData.data
|
||||
}
|
||||
r.error = newData.error
|
||||
r.isValidating = newData.isValidating
|
||||
r.isLoading = newData.isValidating
|
||||
|
||||
const isLast = idx === refs.length - 1
|
||||
if (!isLast) {
|
||||
// Clean up refs that belonged to old keys
|
||||
delete refs[idx]
|
||||
}
|
||||
})
|
||||
|
||||
refs = refs.filter(Boolean)
|
||||
}
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
|
||||
function useSWRV<Data = any, Error = any>(
|
||||
key: IKey
|
||||
): IResponse<Data, Error>
|
||||
function useSWRV<Data = any, Error = any>(
|
||||
key: IKey,
|
||||
fn: fetcherFn<Data> | undefined | null,
|
||||
config?: IConfig
|
||||
): IResponse<Data, Error>
|
||||
function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error> {
|
||||
const injectedConfig = inject<Partial<IConfig> | null>('swrv-config', null)
|
||||
tinyassert(injectedConfig, 'Injected swrv-config must be an object')
|
||||
let key: IKey
|
||||
let fn: fetcherFn<Data> | undefined | null
|
||||
let config: IConfig = { ...defaultConfig, ...injectedConfig }
|
||||
let unmounted = false
|
||||
let isHydrated = false
|
||||
|
||||
const instance = getCurrentInstance() as any
|
||||
const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520
|
||||
if (!vm) {
|
||||
console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.')
|
||||
throw new Error('Could not get current instance')
|
||||
}
|
||||
|
||||
const IS_SERVER = typeof window === 'undefined' || false
|
||||
// #region ssr
|
||||
const isSsrHydration = Boolean(
|
||||
!IS_SERVER &&
|
||||
window !== undefined && (window as any).window.swrv)
|
||||
// #endregion
|
||||
if (args.length >= 1) {
|
||||
key = args[0]
|
||||
}
|
||||
if (args.length >= 2) {
|
||||
fn = args[1]
|
||||
}
|
||||
if (args.length > 2) {
|
||||
config = {
|
||||
...config,
|
||||
...args[2]
|
||||
}
|
||||
}
|
||||
|
||||
const ttl = IS_SERVER ? config.serverTTL : config.ttl
|
||||
const keyRef = typeof key === 'function' ? (key as any) : ref(key)
|
||||
|
||||
if (typeof fn === 'undefined') {
|
||||
// use the global fetcher
|
||||
fn = config.fetcher
|
||||
}
|
||||
|
||||
let stateRef: StateRef<Data, Error> | null = null
|
||||
// #region ssr
|
||||
if (isSsrHydration) {
|
||||
// component was ssrHydrated, so make the ssr reactive as the initial data
|
||||
|
||||
const swrvState = (window as any).window.swrv || []
|
||||
const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
||||
if (swrvKey !== undefined && swrvKey !== null) {
|
||||
const nodeState = swrvState[swrvKey] || []
|
||||
const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())]
|
||||
|
||||
if (instanceState) {
|
||||
stateRef = reactive(instanceState)
|
||||
isHydrated = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
if (!stateRef) {
|
||||
stateRef = reactive({
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
isValidating: true,
|
||||
isLoading: true,
|
||||
key: null
|
||||
}) as StateRef<Data, Error>
|
||||
}
|
||||
|
||||
/**
|
||||
* Revalidate the cache, mutate data
|
||||
*/
|
||||
const revalidate = async (data?: fetcherFn<Data>, opts?: revalidateOptions) => {
|
||||
const isFirstFetch = stateRef.data === undefined
|
||||
const keyVal = keyRef.value
|
||||
if (!keyVal) { return }
|
||||
|
||||
const cacheItem = config.cache!.get(keyVal)
|
||||
const newData = cacheItem && cacheItem.data
|
||||
|
||||
stateRef.isValidating = true
|
||||
stateRef.isLoading = !newData
|
||||
if (newData) {
|
||||
stateRef.data = newData.data
|
||||
stateRef.error = newData.error
|
||||
}
|
||||
|
||||
const fetcher = data || fn
|
||||
if (
|
||||
!fetcher ||
|
||||
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
|
||||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
|
||||
) {
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
return
|
||||
}
|
||||
|
||||
// Dedupe items that were created in the last interval #76
|
||||
if (cacheItem) {
|
||||
const shouldRevalidate = Boolean(
|
||||
((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate
|
||||
)
|
||||
|
||||
if (!shouldRevalidate) {
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = async () => {
|
||||
const promiseFromCache = PROMISES_CACHE.get(keyVal)
|
||||
if (!promiseFromCache) {
|
||||
const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal]
|
||||
const newPromise = fetcher(...fetcherArgs)
|
||||
PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval)
|
||||
await mutate(keyVal, newPromise, (config as any).cache, ttl)
|
||||
} else {
|
||||
await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl)
|
||||
}
|
||||
stateRef.isValidating = false
|
||||
stateRef.isLoading = false
|
||||
PROMISES_CACHE.delete(keyVal)
|
||||
if (stateRef.error !== undefined) {
|
||||
const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true)
|
||||
if (shouldRetryOnError) {
|
||||
onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newData && config.revalidateDebounce) {
|
||||
setTimeout(async () => {
|
||||
if (!unmounted) {
|
||||
await trigger()
|
||||
}
|
||||
}, config.revalidateDebounce)
|
||||
} else {
|
||||
await trigger()
|
||||
}
|
||||
}
|
||||
|
||||
const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false })
|
||||
let timer: any = null
|
||||
/**
|
||||
* Setup polling
|
||||
*/
|
||||
onMounted(() => {
|
||||
const tick = async () => {
|
||||
// component might un-mount during revalidate, so do not set a new timeout
|
||||
// if this is the case, but continue to revalidate since promises can't
|
||||
// be cancelled and new hook instances might rely on promise/data cache or
|
||||
// from pre-fetch
|
||||
if (!stateRef.error && (config as any).isOnline()) {
|
||||
// if API request errored, we stop polling in this round
|
||||
// and let the error retry function handle it
|
||||
await revalidate()
|
||||
} else {
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.refreshInterval && !unmounted) {
|
||||
timer = setTimeout(tick, config.refreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
if (config.refreshInterval) {
|
||||
timer = setTimeout(tick, config.refreshInterval)
|
||||
}
|
||||
if (config.revalidateOnFocus) {
|
||||
document.addEventListener('visibilitychange', revalidateCall, false)
|
||||
window.addEventListener('focus', revalidateCall, false)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Teardown
|
||||
*/
|
||||
onUnmounted(() => {
|
||||
unmounted = true
|
||||
if (timer) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
if (config.revalidateOnFocus) {
|
||||
document.removeEventListener('visibilitychange', revalidateCall, false)
|
||||
window.removeEventListener('focus', revalidateCall, false)
|
||||
}
|
||||
const refCacheItem = REF_CACHE.get(keyRef.value)
|
||||
if (refCacheItem) {
|
||||
refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef)
|
||||
}
|
||||
})
|
||||
|
||||
// #region ssr
|
||||
if (IS_SERVER) {
|
||||
const ssrContext = useSSRContext()
|
||||
// make sure srwv exists in ssrContext
|
||||
let swrvRes: Record<string, any> = {}
|
||||
if (ssrContext) {
|
||||
swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes
|
||||
}
|
||||
|
||||
const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
|
||||
// if (!vm.$vnode || (vm.$node && !vm.$node.data)) {
|
||||
// vm.$vnode = {
|
||||
// data: { attrs: { 'data-swrv-key': ssrKey } }
|
||||
// }
|
||||
// }
|
||||
|
||||
// const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
|
||||
// attrs['data-swrv-key'] = ssrKey
|
||||
// // Nuxt compatibility
|
||||
// if (vm.$ssrContext && vm.$ssrContext.nuxt) {
|
||||
// vm.$ssrContext.nuxt.swrv = swrvRes
|
||||
// }
|
||||
if (ssrContext) {
|
||||
ssrContext.swrv = swrvRes
|
||||
}
|
||||
onServerPrefetch(async () => {
|
||||
await revalidate()
|
||||
if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {}
|
||||
|
||||
swrvRes[ssrKey][nanoHex(keyRef.value)] = {
|
||||
data: stateRef.data,
|
||||
error: stateRef.error,
|
||||
isValidating: stateRef.isValidating
|
||||
}
|
||||
})
|
||||
}
|
||||
// #endregion
|
||||
|
||||
/**
|
||||
* Revalidate when key dependencies change
|
||||
*/
|
||||
try {
|
||||
watch(keyRef, (val) => {
|
||||
if (!isReadonly(keyRef)) {
|
||||
keyRef.value = val
|
||||
}
|
||||
stateRef.key = val
|
||||
stateRef.isValidating = Boolean(val)
|
||||
setRefCache(keyRef.value, stateRef, Number(ttl))
|
||||
|
||||
if (!IS_SERVER && !isHydrated && keyRef.value) {
|
||||
revalidate()
|
||||
}
|
||||
isHydrated = false
|
||||
}, {
|
||||
immediate: true
|
||||
})
|
||||
} catch {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
const res: IResponse = {
|
||||
...toRefs(stateRef),
|
||||
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => revalidate(data, {
|
||||
...opts,
|
||||
forceRevalidate: true
|
||||
})
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
function isPromise<T>(p: any): p is Promise<T> {
|
||||
return p !== null && typeof p === 'object' && typeof p.then === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* string to hex 8 chars
|
||||
* @param name string
|
||||
* @returns string
|
||||
*/
|
||||
function nanoHex(name: string): string {
|
||||
try {
|
||||
let hash = 0
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
const chr = name.charCodeAt(i)
|
||||
hash = ((hash << 5) - hash) + chr
|
||||
hash |= 0 // Convert to 32bit integer
|
||||
}
|
||||
let hex = (hash >>> 0).toString(16)
|
||||
while (hex.length < 8) {
|
||||
hex = '0' + hex
|
||||
}
|
||||
return hex
|
||||
} catch {
|
||||
console.error("err name: ", name)
|
||||
return '0000'
|
||||
}
|
||||
}
|
||||
export const vueSWR = (swrvConfig: Partial<IConfig> = defaultConfig): FunctionPlugin => (app) => {
|
||||
app.config.globalProperties.$swrv = useSWRV
|
||||
// app.provide('swrv', useSWRV)
|
||||
app.provide('swrv-config', swrvConfig)
|
||||
}
|
||||
export { mutate };
|
||||
export default useSWRV
|
||||
@@ -52,41 +52,45 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
||||
|
||||
|
||||
export const formatBytes = (bytes?: number) => {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
export const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
if (!seconds) return '0:00';
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
if (h > 0) {
|
||||
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
export const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
...(dateOnly ? {} : { hour: '2-digit', minute: '2-digit' })
|
||||
});
|
||||
};
|
||||
|
||||
export const getStatusClass = (status?: string) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
export const getStatusSeverity = (status: string = "") => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
case 'ready':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
case 'pending':
|
||||
return 'warn';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
54
src/main.ts
54
src/main.ts
@@ -1,16 +1,12 @@
|
||||
import { PiniaColada, useQueryCache } from '@pinia/colada';
|
||||
import { createHead as CSRHead } from "@unhead/vue/client";
|
||||
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||
import { createPinia } from "pinia";
|
||||
import { createSSRApp } from 'vue';
|
||||
import { RouterView } from 'vue-router';
|
||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||
import { vueSWR } from './lib/swr/use-swrv';
|
||||
import createAppRouter from './routes';
|
||||
import PrimeVue from 'primevue/config';
|
||||
import Aura from '@primeuix/themes/aura';
|
||||
import { createPinia } from "pinia";
|
||||
import { useAuthStore } from './stores/auth';
|
||||
import ToastService from 'primevue/toastservice';
|
||||
import Tooltip from 'primevue/tooltip';
|
||||
|
||||
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
|
||||
export function createApp() {
|
||||
const pinia = createPinia();
|
||||
@@ -18,27 +14,30 @@ export function createApp() {
|
||||
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
|
||||
|
||||
app.use(head);
|
||||
app.use(PrimeVue, {
|
||||
// unstyled: true,
|
||||
theme: {
|
||||
preset: Aura,
|
||||
options: {
|
||||
darkModeSelector: '.my-app-dark',
|
||||
cssLayer: false,
|
||||
// cssLayer: {
|
||||
// name: 'primevue',
|
||||
// order: 'theme, base, primevue'
|
||||
// }
|
||||
}
|
||||
}
|
||||
});
|
||||
app.use(ToastService);
|
||||
app.directive('nh', {
|
||||
created(el) {
|
||||
el.__v_skip = true;
|
||||
}
|
||||
});
|
||||
app.directive("tooltip", Tooltip)
|
||||
app.use(pinia);
|
||||
app.use(PiniaColada, {
|
||||
pinia,
|
||||
plugins: [
|
||||
(context) => {
|
||||
// console.log("PiniaColada plugin initialized for store:", context);
|
||||
}
|
||||
],
|
||||
queryOptions: {
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
ssrCatchError: true,
|
||||
}
|
||||
// optional options
|
||||
})
|
||||
// app.use(vueSWR({ revalidateOnFocus: false }));
|
||||
const queryCache = useQueryCache();
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
if (!import.meta.env.SSR) {
|
||||
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||
(window as any)[key] = value;
|
||||
@@ -47,10 +46,5 @@ export function createApp() {
|
||||
pinia.state.value = (window as any).$p;
|
||||
}
|
||||
}
|
||||
app.use(pinia);
|
||||
app.use(vueSWR({ revalidateOnFocus: false }));
|
||||
const router = createAppRouter();
|
||||
app.use(router);
|
||||
|
||||
return { app, router, head, pinia, bodyClass };
|
||||
}
|
||||
return { app, router, head, pinia, bodyClass, queryCache };
|
||||
}
|
||||
|
||||
@@ -319,3 +319,39 @@ export const fetchMockVideos = async ({ page, limit, searchQuery, status }: Fetc
|
||||
total
|
||||
};
|
||||
};
|
||||
export const fetchMockVideoById = async (id: string) => {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
const video = mockVideos.find(v => v.id === id);
|
||||
if (!video) {
|
||||
throw new Error('Video not found');
|
||||
}
|
||||
return video;
|
||||
};
|
||||
|
||||
export const updateMockVideo = async (id: string, updates: { title: string; description?: string }) => {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 800));
|
||||
const videoIndex = mockVideos.findIndex(v => v.id === id);
|
||||
if (videoIndex === -1) {
|
||||
throw new Error('Video not found');
|
||||
}
|
||||
mockVideos[videoIndex] = {
|
||||
...mockVideos[videoIndex],
|
||||
title: updates.title,
|
||||
description: updates.description,
|
||||
updated_at: new Date().toISOString()
|
||||
};
|
||||
return mockVideos[videoIndex];
|
||||
};
|
||||
|
||||
export const deleteMockVideo = async (id: string) => {
|
||||
// Simulate API delay
|
||||
await new Promise(resolve => setTimeout(resolve, 600));
|
||||
const videoIndex = mockVideos.findIndex(v => v.id === id);
|
||||
if (videoIndex === -1) {
|
||||
throw new Error('Video not found');
|
||||
}
|
||||
mockVideos.splice(videoIndex, 1);
|
||||
return { success: true };
|
||||
};
|
||||
@@ -1,397 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { client, type ModelVideo } from '@/api/client';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
const router = useRouter();
|
||||
const loading = ref(true);
|
||||
const recentVideos = ref<ModelVideo[]>([]);
|
||||
|
||||
// Mock stats data (in real app, fetch from API)
|
||||
const stats = ref({
|
||||
totalVideos: 0,
|
||||
totalViews: 0,
|
||||
storageUsed: 0,
|
||||
storageLimit: 10737418240, // 10GB in bytes
|
||||
uploadsThisMonth: 0
|
||||
});
|
||||
|
||||
const quickActions = [
|
||||
{
|
||||
title: 'Upload Video',
|
||||
description: 'Upload a new video to your library',
|
||||
icon: 'i-heroicons-cloud-arrow-up',
|
||||
color: 'bg-gradient-to-br from-primary/20 to-primary/5',
|
||||
iconColor: 'text-primary',
|
||||
onClick: () => router.push('/upload')
|
||||
},
|
||||
{
|
||||
title: 'Video Library',
|
||||
description: 'Browse all your videos',
|
||||
icon: 'i-heroicons-film',
|
||||
color: 'bg-gradient-to-br from-blue-100 to-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => router.push('/video')
|
||||
},
|
||||
{
|
||||
title: 'Analytics',
|
||||
description: 'Track performance & insights',
|
||||
icon: 'i-heroicons-chart-bar',
|
||||
color: 'bg-gradient-to-br from-purple-100 to-purple-50',
|
||||
iconColor: 'text-purple-600',
|
||||
onClick: () => {}
|
||||
},
|
||||
{
|
||||
title: 'Manage Plan',
|
||||
description: 'Upgrade or change your plan',
|
||||
icon: 'i-heroicons-credit-card',
|
||||
color: 'bg-gradient-to-br from-orange-100 to-orange-50',
|
||||
iconColor: 'text-orange-600',
|
||||
onClick: () => router.push('/plans')
|
||||
},
|
||||
];
|
||||
|
||||
const fetchDashboardData = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
// Fetch recent videos
|
||||
const response = await client.videos.videosList({ page: 1, limit: 5 });
|
||||
const body = response.data as any;
|
||||
|
||||
if (body.data && Array.isArray(body.data)) {
|
||||
recentVideos.value = body.data;
|
||||
stats.value.totalVideos = body.data.length;
|
||||
} else if (Array.isArray(body)) {
|
||||
recentVideos.value = body;
|
||||
stats.value.totalVideos = body.length;
|
||||
}
|
||||
|
||||
// Calculate mock stats
|
||||
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
|
||||
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
|
||||
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
|
||||
const uploadDate = new Date(v.created_at || '');
|
||||
const now = new Date();
|
||||
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
|
||||
}).length;
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch dashboard data:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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:00';
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = Math.floor(seconds % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const formatDate = (dateString?: string) => {
|
||||
if (!dateString) return '';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusClass = (status?: string) => {
|
||||
switch(status?.toLowerCase()) {
|
||||
case 'ready': return 'bg-green-100 text-green-700';
|
||||
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'failed': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
const storagePercentage = computed(() => {
|
||||
return Math.round((stats.value.storageUsed / stats.value.storageLimit) * 100);
|
||||
});
|
||||
|
||||
const storageBreakdown = computed(() => {
|
||||
const videoSize = stats.value.storageUsed;
|
||||
const thumbSize = stats.value.totalVideos * 300 * 1024; // ~300KB per thumbnail
|
||||
const otherSize = stats.value.totalVideos * 100 * 1024; // ~100KB other files
|
||||
const total = videoSize + thumbSize + otherSize;
|
||||
|
||||
return [
|
||||
{ label: 'Videos', size: videoSize, percentage: (videoSize / total) * 100, color: 'bg-primary' },
|
||||
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / total) * 100, color: 'bg-blue-500' },
|
||||
{ label: 'Other Files', size: otherSize, percentage: (otherSize / total) * 100, color: 'bg-gray-400' },
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
fetchDashboardData();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard-overview">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
description="Welcome back! Here's what's happening with your videos."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="loading" class="animate-pulse">
|
||||
<!-- Stats Grid Skeleton -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="8rem" height="2rem"></Skeleton>
|
||||
</div>
|
||||
<Skeleton shape="circle" size="3rem"></Skeleton>
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions Skeleton -->
|
||||
<div class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos Skeleton -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton width="8rem" height="1.5rem"></Skeleton>
|
||||
<Skeleton width="5rem" height="1rem"></Skeleton>
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4">
|
||||
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||
<Skeleton width="20%" height="0.8rem"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
<StatsCard
|
||||
title="Total Videos"
|
||||
:value="stats.totalVideos"
|
||||
icon="i-heroicons-film"
|
||||
color="primary"
|
||||
:trend="{ value: 12, isPositive: true }"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Total Views"
|
||||
:value="stats.totalViews.toLocaleString()"
|
||||
icon="i-heroicons-eye"
|
||||
color="info"
|
||||
:trend="{ value: 8, isPositive: true }"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Storage Used"
|
||||
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
|
||||
icon="i-heroicons-server"
|
||||
color="warning"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
title="Uploads This Month"
|
||||
:value="stats.uploadsThisMonth"
|
||||
icon="i-heroicons-arrow-up-tray"
|
||||
color="success"
|
||||
:trend="{ value: 25, isPositive: true }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<button
|
||||
v-for="action in quickActions"
|
||||
:key="action.title"
|
||||
@click="action.onClick"
|
||||
:class="[
|
||||
'p-6 rounded-xl text-left transition-all duration-200',
|
||||
'border border-gray-200 hover:border-primary hover:shadow-lg',
|
||||
'group press-animated',
|
||||
action.color
|
||||
]"
|
||||
>
|
||||
<div :class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-white/80', action.iconColor]">
|
||||
<span :class="[action.icon, 'w-6 h-6']" />
|
||||
</div>
|
||||
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
|
||||
<p class="text-sm text-gray-600">{{ action.description }}</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Videos -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold">Recent Videos</h2>
|
||||
<router-link
|
||||
to="/video"
|
||||
class="text-sm text-primary hover:underline font-medium flex items-center gap-1"
|
||||
>
|
||||
View all
|
||||
<span class="i-heroicons-arrow-right w-4 h-4" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="recentVideos.length === 0" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-4">
|
||||
<span class="i-heroicons-film w-8 h-8 text-gray-400" />
|
||||
</div>
|
||||
<p class="text-gray-600 mb-4">No videos yet</p>
|
||||
<router-link
|
||||
to="/upload"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
|
||||
>
|
||||
<span class="i-heroicons-plus w-5 h-5" />
|
||||
Upload your first video
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200">
|
||||
<tr v-for="video in recentVideos" :key="video.id" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-16 h-10 bg-gray-200 rounded overflow-hidden flex-shrink-0">
|
||||
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover" />
|
||||
<div v-else class="w-full h-full flex items-center justify-center">
|
||||
<span class="i-heroicons-film text-gray-400 text-xl" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
|
||||
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
|
||||
{{ video.status || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDuration(video.duration) }}
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-gray-500">
|
||||
{{ formatDate(video.created_at) }}
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
|
||||
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
|
||||
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete">
|
||||
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Storage Usage -->
|
||||
<div class="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-sm font-medium text-gray-700">
|
||||
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
|
||||
</span>
|
||||
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
|
||||
{{ storagePercentage }}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500 rounded-full"
|
||||
:class="storagePercentage > 80 ? 'bg-danger' : 'bg-primary'"
|
||||
:style="{ width: `${storagePercentage}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="item in storageBreakdown"
|
||||
:key="item.label"
|
||||
class="flex items-center justify-between text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<div :class="['w-3 h-3 rounded-sm', item.color]" />
|
||||
<span class="text-gray-700">{{ item.label }}</span>
|
||||
</div>
|
||||
<span class="text-gray-500">{{ formatBytes(item.size) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="storagePercentage > 80" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="flex gap-2">
|
||||
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
|
||||
<p class="text-sm text-yellow-700 mt-1">
|
||||
Consider upgrading your plan to get more storage.
|
||||
<router-link to="/plans" class="underline font-medium">View plans</router-link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,20 +1,17 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Toast />
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="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">
|
||||
Enter your email address and we'll send you a link to reset your password.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
<AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
|
||||
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="small" label="Send Reset Link" fluid />
|
||||
<AppButton type="submit" class="w-full">Send Reset Link</AppButton>
|
||||
|
||||
<div class="text-center mt-2">
|
||||
<router-link to="/login" replace
|
||||
@@ -26,48 +23,46 @@
|
||||
Back to Sign in
|
||||
</router-link>
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import Toast from 'primevue/toast';
|
||||
import { client } from '@/api/client';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { reactive } from 'vue';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { client } from '@/api/client';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
const toast = useAppToast();
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
const initialValues = reactive({
|
||||
const form = reactive({
|
||||
email: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
|
||||
})
|
||||
);
|
||||
const errors = reactive<{ email?: string }>({});
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
client.auth.forgotPasswordCreate({ email: values.email })
|
||||
.then(() => {
|
||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
||||
});
|
||||
// forgotPassword(values.email).then(() => {
|
||||
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
// }).catch(() => {
|
||||
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
|
||||
// });
|
||||
const schema = z.object({
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
|
||||
});
|
||||
|
||||
const onFormSubmit = () => {
|
||||
errors.email = undefined;
|
||||
|
||||
const result = schema.safeParse(form);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof typeof errors;
|
||||
if (field in errors) errors[field] = issue.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
client.auth.forgotPasswordCreate({ email: form.email })
|
||||
.then(() => {
|
||||
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
|
||||
})
|
||||
.catch((error) => {
|
||||
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,27 +1,41 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Toast />
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="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">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
|
||||
<InputText size="small" name="email" type="text" placeholder="Enter your email" fluid
|
||||
<AppInput id="email" v-model="form.email" type="text" placeholder="Enter your email"
|
||||
:disabled="auth.loading" />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" size="small" placeholder="Enter your password" :feedback="false" toggleMask
|
||||
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
<div class="relative">
|
||||
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
|
||||
placeholder="Enter your password" :disabled="auth.loading" />
|
||||
<button type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
|
||||
@click="showPassword = !showPassword" tabindex="-1">
|
||||
<svg v-if="!showPassword" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<Checkbox inputId="remember-me" size="small" name="rememberMe" binary :disabled="auth.loading" />
|
||||
<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">
|
||||
@@ -31,8 +45,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="small" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
|
||||
:loading="auth.loading" />
|
||||
<AppButton type="submit" :loading="auth.loading" class="w-full">
|
||||
{{ auth.loading ? 'Signing in...' : 'Sign in' }}
|
||||
</AppButton>
|
||||
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 flex items-center">
|
||||
@@ -43,60 +58,71 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button size="small" type="button" variant="outlined" severity="secondary"
|
||||
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
|
||||
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2"
|
||||
@click="loginWithGoogle" :disabled="auth.loading">
|
||||
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
|
||||
</svg>
|
||||
Google
|
||||
</Button>
|
||||
</AppButton>
|
||||
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
|
||||
<p class="text-center text-sm text-gray-600">
|
||||
Don't have an account?
|
||||
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
|
||||
<router-link to="/sign-up"
|
||||
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
|
||||
</p>
|
||||
<!-- <router-link to="/forgot" class="text-blue-600 hover:text-blue-500 hover:underline">Forgot password?</router-link> -->
|
||||
</div>
|
||||
</Form>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import Toast from 'primevue/toast';
|
||||
import { useToast } from "primevue/usetoast";
|
||||
import { reactive } from 'vue';
|
||||
import { useAppToast } from '@/composables/useAppToast';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { z } from 'zod';
|
||||
const t = useToast();
|
||||
const auth = useAuthStore();
|
||||
// const $form = Form.useFormContext();
|
||||
watch(() => auth.error, (newError) => {
|
||||
if (newError) {
|
||||
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
const initialValues = reactive({
|
||||
const toast = useAppToast();
|
||||
const auth = useAuthStore();
|
||||
const showPassword = ref(false);
|
||||
|
||||
const form = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
rememberMe: false
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
||||
password: z.string().min(1, { message: 'Password is required.' })
|
||||
})
|
||||
);
|
||||
const errors = reactive<{ email?: string; password?: string }>({});
|
||||
|
||||
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) auth.login(values.email, values.password);
|
||||
const schema = z.object({
|
||||
email: z.string().min(1, { message: 'Email or username is required.' }),
|
||||
password: z.string().min(1, { message: 'Password is required.' })
|
||||
});
|
||||
|
||||
watch(() => auth.error, (newError) => {
|
||||
if (newError) {
|
||||
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
|
||||
}
|
||||
});
|
||||
|
||||
const onFormSubmit = () => {
|
||||
errors.email = undefined;
|
||||
errors.password = undefined;
|
||||
|
||||
const result = schema.safeParse(form);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof typeof errors;
|
||||
if (field in errors) errors[field] = issue.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
auth.login(form.email, form.password);
|
||||
};
|
||||
|
||||
const loginWithGoogle = () => {
|
||||
auth.loginWithGoogle();
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,70 +1,89 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="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">
|
||||
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
|
||||
<InputText size="small" name="name" placeholder="John Doe" fluid />
|
||||
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.name.error?.message }}</Message>
|
||||
<AppInput id="name" v-model="form.name" placeholder="John Doe" />
|
||||
<p v-if="errors.name" class="text-xs text-red-500 mt-0.5">{{ errors.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
|
||||
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
|
||||
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.email.error?.message }}</Message>
|
||||
<AppInput id="email" v-model="form.email" type="email" placeholder="you@example.com" />
|
||||
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
|
||||
<Password name="password" size="small" placeholder="Create a password" :feedback="true" toggleMask fluid
|
||||
:inputStyle="{ width: '100%' }" />
|
||||
<div class="relative">
|
||||
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
|
||||
placeholder="Create a password" />
|
||||
<button type="button"
|
||||
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
|
||||
@click="showPassword = !showPassword" tabindex="-1">
|
||||
<svg v-if="!showPassword" 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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
<svg v-else 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="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<small class="text-gray-500">Must be at least 8 characters.</small>
|
||||
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
|
||||
$form.password.error?.message }}</Message>
|
||||
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
|
||||
</div>
|
||||
|
||||
<Button type="submit" size="small" label="Create Account" fluid />
|
||||
<AppButton type="submit" class="w-full">Create Account</AppButton>
|
||||
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
Already have an account?
|
||||
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
|
||||
in</router-link>
|
||||
<router-link to="/login"
|
||||
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign in</router-link>
|
||||
</p>
|
||||
</Form>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
import { Form, type FormSubmitEvent } from '@primevue/forms';
|
||||
import { zodResolver } from '@primevue/forms/resolvers/zod';
|
||||
import { reactive } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { reactive, ref } from 'vue';
|
||||
import { z } from 'zod';
|
||||
|
||||
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const showPassword = ref(false);
|
||||
|
||||
const initialValues = reactive({
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
const resolver = zodResolver(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: 'Name is required.' }),
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
|
||||
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
|
||||
})
|
||||
);
|
||||
const errors = reactive<{ name?: string; email?: string; password?: string }>({});
|
||||
|
||||
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
|
||||
if (valid) {
|
||||
auth.register(values.name, values.email, values.password);
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, { message: 'Name is required.' }),
|
||||
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
|
||||
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
|
||||
});
|
||||
|
||||
const onFormSubmit = () => {
|
||||
errors.name = undefined;
|
||||
errors.email = undefined;
|
||||
errors.password = undefined;
|
||||
|
||||
const result = schema.safeParse(form);
|
||||
if (!result.success) {
|
||||
for (const issue of result.error.issues) {
|
||||
const field = issue.path[0] as keyof typeof errors;
|
||||
if (field in errors) errors[field] = issue.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
auth.register(form.name, form.email, form.password);
|
||||
};
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||
import { headSymbol } from "@unhead/vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { headSymbol, type ReactiveHead, type ResolvableValue } from "@unhead/vue";
|
||||
import { inject } from "vue";
|
||||
import {
|
||||
createMemoryHistory,
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
type RouteRecordRaw,
|
||||
} from "vue-router";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { inject } from "vue";
|
||||
|
||||
type RouteData = RouteRecordRaw & {
|
||||
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
|
||||
@@ -25,12 +24,10 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./home/Home.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
beforeEnter: (to, from) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.user) {
|
||||
next({ name: "overview" });
|
||||
} else {
|
||||
next();
|
||||
return { name: "overview" };
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -49,12 +46,10 @@ const routes: RouteData[] = [
|
||||
{
|
||||
path: "",
|
||||
component: () => import("./auth/layout.vue"),
|
||||
beforeEnter: (to, from, next) => {
|
||||
beforeEnter: (to, from) => {
|
||||
const auth = useAuthStore();
|
||||
if (auth.user) {
|
||||
next({ name: "overview" });
|
||||
} else {
|
||||
next();
|
||||
return { name: "overview" };
|
||||
}
|
||||
},
|
||||
children: [
|
||||
@@ -90,47 +85,46 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: "upload",
|
||||
// name: "upload",
|
||||
// component: () => import("./upload/Upload.vue"),
|
||||
// meta: {
|
||||
// head: {
|
||||
// title: "Upload - Holistream",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
{
|
||||
path: "upload",
|
||||
name: "upload",
|
||||
component: () => import("./upload/Upload.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Upload - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "video",
|
||||
name: "video",
|
||||
component: () => import("./video/Videos.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Videos - Holistream",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your video content.",
|
||||
path: "videos",
|
||||
children: [
|
||||
{
|
||||
path: "",
|
||||
name: "videos",
|
||||
component: () => import("./video/Videos.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Videos - Holistream",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your video content.",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "payments-and-plans",
|
||||
name: "payments-and-plans",
|
||||
component: () => import("./plans/Plans.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Payments & Plans - Holistream",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your plans and billing information.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// {
|
||||
// path: ":id",
|
||||
// name: "video-detail",
|
||||
// component: () => import("./video/DetailVideo.vue"),
|
||||
// meta: {
|
||||
// head: {
|
||||
// title: "Edit Video - Holistream",
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "notification",
|
||||
@@ -143,14 +137,99 @@ const routes: RouteData[] = [
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "profile",
|
||||
name: "profile",
|
||||
component: () => import("./profile/Profile.vue"), // TODO: create profile page
|
||||
path: "settings",
|
||||
name: "settings",
|
||||
component: () => import("./settings/Settings.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Profile - Holistream",
|
||||
title: "Settings - Holistream",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your account settings and preferences.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirect: '/settings/security',
|
||||
children: [
|
||||
{
|
||||
path: "security",
|
||||
name: "settings-security",
|
||||
component: () => import("./settings/pages/SecurityNConnected.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Security & Connected Apps - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "billing",
|
||||
name: "settings-billing",
|
||||
component: () => import("./settings/pages/Billing.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Billing & Plans - Holistream",
|
||||
meta: [
|
||||
{
|
||||
name: "description",
|
||||
content: "Manage your plans and billing information.",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "notifications",
|
||||
name: "settings-notifications",
|
||||
component: () => import("./settings/pages/NotificationSettings.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Notifications - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "player",
|
||||
name: "settings-player",
|
||||
component: () => import("./settings/pages/PlayerSettings.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Player Settings - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "domains",
|
||||
name: "settings-domains",
|
||||
component: () => import("./settings/pages/DomainsDns.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Allowed Domains - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "ads",
|
||||
name: "settings-ads",
|
||||
component: () => import("./settings/pages/AdsVast.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Ads & VAST - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "danger",
|
||||
name: "settings-danger",
|
||||
component: () => import("./settings/pages/DangerZone.vue"),
|
||||
meta: {
|
||||
head: {
|
||||
title: "Danger Zone - Holistream",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -164,30 +243,26 @@ const routes: RouteData[] = [
|
||||
];
|
||||
const createAppRouter = () => {
|
||||
const router = createRouter({
|
||||
history: import.meta.env.SSR
|
||||
? createMemoryHistory() // server
|
||||
: createWebHistory(), // client
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition
|
||||
}
|
||||
return { top: 0 }
|
||||
}
|
||||
});
|
||||
history: import.meta.env.SSR
|
||||
? createMemoryHistory() // server
|
||||
: createWebHistory(), // client
|
||||
routes,
|
||||
scrollBehavior(to, from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
}
|
||||
return { top: 0 };
|
||||
},
|
||||
});
|
||||
|
||||
router.beforeEach((to, from, next) => {
|
||||
router.beforeEach((to, from) => {
|
||||
const auth = useAuthStore();
|
||||
const head = inject(headSymbol);
|
||||
(head as any).push(to.meta.head || {});
|
||||
if (to.matched.some((record) => record.meta.requiresAuth)) {
|
||||
if (!auth.user) {
|
||||
next({ name: "login" });
|
||||
} else {
|
||||
next();
|
||||
return { name: "login" };
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
return router;
|
||||
|
||||
@@ -3,16 +3,16 @@ import Chart from '@/components/icons/Chart.vue';
|
||||
import Credit from '@/components/icons/Credit.vue';
|
||||
import Upload from '@/components/icons/Upload.vue';
|
||||
import Video from '@/components/icons/Video.vue';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import { useRouter } from 'vue-router';
|
||||
import Referral from './Referral.vue';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
const uiState = useUIState();
|
||||
const router = useRouter();
|
||||
|
||||
const quickActions = [
|
||||
@@ -20,7 +20,7 @@ const quickActions = [
|
||||
title: 'Upload Video',
|
||||
description: 'Upload a new video to your library',
|
||||
icon: Upload,
|
||||
onClick: () => router.push('/upload')
|
||||
onClick: () => uiState.toggleUploadDialog()
|
||||
},
|
||||
{
|
||||
title: 'Video Library',
|
||||
@@ -45,19 +45,19 @@ const quickActions = [
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="mb-8">
|
||||
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
|
||||
<div class="w-40 h-6 bg-gray-200 rounded animate-pulse mb-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 v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
|
||||
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
<div class="w-12 h-12 bg-gray-200 rounded-full animate-pulse mb-4" />
|
||||
<div class="w-32 h-5 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
<div class="w-full h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between p-6 rounded-xl border border-gray-200">
|
||||
<Skeleton width="10rem" height="2rem"></Skeleton>
|
||||
<Skeleton width="100%" height="1.25rem" class="my-4"></Skeleton>
|
||||
<Skeleton width="100%" height="1rem"></Skeleton>
|
||||
<div class="w-40 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-full h-5 bg-gray-200 rounded animate-pulse my-4" />
|
||||
<div class="w-full h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { ModelVideo } from '@/api/client';
|
||||
import EmptyState from '@/components/dashboard/EmptyState.vue';
|
||||
import { formatBytes, formatDate, formatDuration } from '@/lib/utils';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
interface Props {
|
||||
@@ -28,16 +27,16 @@ const getStatusClass = (status?: string) => {
|
||||
<div class="mb-8">
|
||||
<div v-if="loading">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<Skeleton width="8rem" height="1.5rem"></Skeleton>
|
||||
<Skeleton width="5rem" height="1rem"></Skeleton>
|
||||
<div class="w-32 h-6 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
|
||||
<div class="flex gap-4">
|
||||
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
|
||||
<div class="w-16 h-10 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="flex-1 space-y-2">
|
||||
<Skeleton width="30%" height="1rem"></Skeleton>
|
||||
<Skeleton width="20%" height="0.8rem"></Skeleton>
|
||||
<div class="w-[30%] h-4 bg-gray-200 rounded animate-pulse" />
|
||||
<div class="w-[20%] h-3 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<p class="text-sm text-gray-600 font-medium">Share your referral link and earn commissions from
|
||||
referred users!</p>
|
||||
<div class="flex gap-2">
|
||||
<InputText class="w-full" readonly type="text" :value="url" @click="copyToClipboard" />
|
||||
<AppInput class="w-full" readonly type="text" :modelValue="url" @click="copyToClipboard" />
|
||||
<button class="btn btn-primary" @click="copyToClipboard" :disabled="isCopied">
|
||||
<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"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import StatsCard from '@/components/dashboard/StatsCard.vue';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
|
||||
interface Props {
|
||||
loading: boolean;
|
||||
@@ -22,12 +21,11 @@ defineProps<Props>();
|
||||
<div v-for="i in 4" :key="i" class="bg-surface rounded-xl border border-gray-200 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="space-y-2">
|
||||
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
|
||||
<Skeleton width="8rem" height="2rem"></Skeleton>
|
||||
<div class="w-20 h-4 bg-gray-200 rounded animate-pulse mb-2" />
|
||||
<div class="w-32 h-8 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
<!-- <Skeleton shape="circle" size="3rem"></Skeleton> -->
|
||||
</div>
|
||||
<Skeleton width="4rem" height="1rem"></Skeleton>
|
||||
<div class="w-16 h-4 bg-gray-200 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,198 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { client, type ModelPlan } from '@/api/client';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import useSWRV from '@/lib/swr';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import CurrentPlanCard from './components/CurrentPlanCard.vue';
|
||||
import UsageStatsCard from './components/UsageStatsCard.vue';
|
||||
import PlanList from './components/PlanList.vue';
|
||||
import PlanPaymentHistory from './components/PlanPaymentHistory.vue';
|
||||
import EditPlanDialog from './components/EditPlanDialog.vue';
|
||||
import ManageSubscriptionDialog from './components/ManageSubscriptionDialog.vue';
|
||||
|
||||
const auth = useAuthStore();
|
||||
// const plans = ref<ModelPlan[]>([]);
|
||||
const subscribing = ref<string | null>(null);
|
||||
const showManageDialog = ref(false);
|
||||
const cancelling = ref(false);
|
||||
|
||||
// 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' },
|
||||
]);
|
||||
const { data, isLoading, mutate: mutatePlans } = useSWRV("r/plans", client.plans.plansList)
|
||||
|
||||
// Computed Usage (Mock if not in store)
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0); // bytes
|
||||
// Default limit 10GB if no plan
|
||||
const storageLimit = computed(() => 10737418240);
|
||||
const uploadsUsed = ref(12);
|
||||
const uploadsLimit = ref(50);
|
||||
|
||||
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; // Fallback to first plan
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
// watch(data, (newValue) => {
|
||||
// if (newValue) {
|
||||
// // Handle potentially different response structures
|
||||
// // Safe access to avoid SSR crash if data is null/undefined
|
||||
// const plansList = newValue?.data?.data?.plans;
|
||||
// if (Array.isArray(plansList)) {
|
||||
// plans.value = plansList;
|
||||
// }
|
||||
// }
|
||||
// }, { immediate: true });
|
||||
|
||||
const showEditDialog = ref(false);
|
||||
const editingPlan = ref<ModelPlan>({});
|
||||
const isSaving = ref(false);
|
||||
|
||||
const openEditPlan = (plan: ModelPlan) => {
|
||||
editingPlan.value = { ...plan };
|
||||
showEditDialog.value = true;
|
||||
};
|
||||
|
||||
const savePlan = async (updatedPlan: ModelPlan) => {
|
||||
isSaving.value = true;
|
||||
try {
|
||||
if (!updatedPlan.id) return;
|
||||
|
||||
// Optimistic update or API call
|
||||
await client.request({
|
||||
path: `/plans/${updatedPlan.id}`,
|
||||
method: 'PUT',
|
||||
body: updatedPlan
|
||||
});
|
||||
|
||||
// Refresh plans
|
||||
await mutatePlans();
|
||||
|
||||
showEditDialog.value = false;
|
||||
alert('Plan updated successfully');
|
||||
} catch (e: any) {
|
||||
console.error('Failed to update plan', e);
|
||||
// Fallback: update local state if API is mocked/missing
|
||||
const idx = data.value!.data.data.plans.findIndex(p => p.id === updatedPlan.id);
|
||||
if (idx !== -1) {
|
||||
data.value!.data.data.plans[idx] = { ...updatedPlan };
|
||||
}
|
||||
showEditDialog.value = false;
|
||||
// alert('Note: API update failed, updated locally. ' + e.message);
|
||||
} finally {
|
||||
isSaving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
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
|
||||
});
|
||||
// Update local state mock
|
||||
// In real app, we would re-fetch user profile
|
||||
alert(`Successfully subscribed to ${plan.name}`);
|
||||
|
||||
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);
|
||||
alert('Failed to subscribe: ' + (err.message || 'Unknown error'));
|
||||
} finally {
|
||||
subscribing.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelSubscription = async () => {
|
||||
cancelling.value = true;
|
||||
try {
|
||||
// Simulate API call
|
||||
await new Promise(resolve => setTimeout(resolve, 1500));
|
||||
alert('Subscription has been canceled.');
|
||||
showManageDialog.value = false;
|
||||
} catch (e) {
|
||||
alert('Failed to cancel subscription.');
|
||||
} finally {
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plans-page">
|
||||
<PageHeader
|
||||
title="Subscription"
|
||||
description="Manage your workspace plan and usage"
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Subscription' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="content max-w-7xl mx-auto space-y-12 pb-12">
|
||||
|
||||
<!-- Hero Section: Current Plan & Usage -->
|
||||
<div v-if="!isLoading" class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<CurrentPlanCard
|
||||
:current-plan="currentPlan"
|
||||
@manage="showManageDialog = true"
|
||||
/>
|
||||
|
||||
<UsageStatsCard
|
||||
:storage-used="storageUsed"
|
||||
:storage-limit="storageLimit"
|
||||
:uploads-used="uploadsUsed"
|
||||
:uploads-limit="uploadsLimit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PlanList
|
||||
:plans="data?.data?.data.plans || []"
|
||||
:is-loading="!!isLoading"
|
||||
:current-plan-id="currentPlanId"
|
||||
:subscribing-plan-id="subscribing"
|
||||
:is-admin="auth.user?.role === 'admin'"
|
||||
@subscribe="subscribe"
|
||||
@edit="openEditPlan"
|
||||
/>
|
||||
|
||||
<PlanPaymentHistory :history="paymentHistory" />
|
||||
|
||||
<ManageSubscriptionDialog
|
||||
v-model:visible="showManageDialog"
|
||||
:current-plan="currentPlan"
|
||||
:cancelling="cancelling"
|
||||
@cancel-subscription="cancelSubscription"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<EditPlanDialog
|
||||
v-model:visible="showEditDialog"
|
||||
:plan="editingPlan"
|
||||
:loading="isSaving"
|
||||
@save="savePlan"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
defineProps<{
|
||||
currentPlan?: ModelPlan;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
(e: 'manage'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class=":uno: lg:col-span-2 relative overflow-hidden rounded-2xl bg-gradient-to-br from-gray-900 to-gray-800 text-white p-8">
|
||||
<!-- Background decorations -->
|
||||
<div class="absolute top-0 right-0 -mt-16 -mr-16 w-64 h-64 bg-primary-500 rounded-full blur-3xl opacity-20"></div>
|
||||
<div class="absolute bottom-0 left-0 -mb-16 -ml-16 w-64 h-64 bg-purple-500 rounded-full blur-3xl opacity-20"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col h-full justify-between">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h2 class="text-sm font-medium text-gray-400 uppercase tracking-wider mb-1">Current Plan</h2>
|
||||
<h3 class="text-4xl font-bold text-white mb-2">{{ currentPlan?.name || 'Standard Plan' }}</h3>
|
||||
<Tag value="Active" severity="success" class="px-3" rounded></Tag>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<div class="text-3xl font-bold text-white">${{ currentPlan?.price || 0 }}<span class="text-lg text-gray-400 font-normal">/mo</span></div>
|
||||
<p class="text-gray-400 text-sm mt-1">Next billing on Feb 24, 2026</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-8 border-t border-gray-700/50 flex gap-4">
|
||||
<Button label="Manage Subscription" severity="secondary" class="bg-white/10 border-white/10 text-white hover:bg-white/20" @click="$emit('manage')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,90 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import Button from 'primevue/button';
|
||||
import Checkbox from 'primevue/checkbox';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputNumber from 'primevue/inputnumber';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Textarea from 'primevue/textarea';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
plan: ModelPlan;
|
||||
loading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'save', plan: ModelPlan): void;
|
||||
}>();
|
||||
|
||||
// Create a local copy to edit
|
||||
const localPlan = ref<ModelPlan>({});
|
||||
|
||||
// Sync when dialog opens or plan changes
|
||||
watch(() => props.plan, (newPlan) => {
|
||||
localPlan.value = { ...newPlan };
|
||||
}, { immediate: true });
|
||||
|
||||
const onSave = () => {
|
||||
emit('save', localPlan.value);
|
||||
};
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visibleModel" modal header="Edit Plan" :style="{ width: '40rem' }">
|
||||
<div class="space-y-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-name" class="text-sm font-medium text-gray-700">Name</label>
|
||||
<InputText id="plan-name" v-model="localPlan.name" placeholder="Plan Name" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-price" class="text-sm font-medium text-gray-700">Price ($)</label>
|
||||
<InputNumber id="plan-price" v-model="localPlan.price" mode="currency" currency="USD" locale="en-US" :minFractionDigits="2" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-cycle" class="text-sm font-medium text-gray-700">Billing Cycle</label>
|
||||
<InputText id="plan-cycle" v-model="localPlan.cycle" placeholder="e.g. month, year" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-desc" class="text-sm font-medium text-gray-700">Description</label>
|
||||
<Textarea id="plan-desc" v-model="localPlan.description" rows="2" class="w-full" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-storage" class="text-sm font-medium text-gray-700">Storage Limit (bytes)</label>
|
||||
<InputNumber id="plan-storage" v-model="localPlan.storage_limit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-uploads" class="text-sm font-medium text-gray-700">Upload Limit (per day)</label>
|
||||
<InputNumber id="plan-uploads" v-model="localPlan.upload_limit" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="plan-duration" class="text-sm font-medium text-gray-700">Duration Limit (sec)</label>
|
||||
<InputNumber id="plan-duration" v-model="localPlan.duration_limit" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 pt-2">
|
||||
<Checkbox v-model="localPlan.is_active" :binary="true" inputId="plan-active" />
|
||||
<label for="plan-active" class="text-sm font-medium text-gray-700">Active</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="Cancel" text severity="secondary" @click="visibleModel = false" />
|
||||
<Button label="Save Changes" icon="i-heroicons-check" @click="onSave" :loading="loading" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,57 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import Button from 'primevue/button';
|
||||
import Dialog from 'primevue/dialog';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
currentPlan?: ModelPlan;
|
||||
cancelling?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:visible', value: boolean): void;
|
||||
(e: 'cancel-subscription'): void;
|
||||
}>();
|
||||
|
||||
const visibleModel = computed({
|
||||
get: () => props.visible,
|
||||
set: (val) => emit('update:visible', val)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:visible="visibleModel" modal header="Manage Subscription" :style="{ width: '30rem' }">
|
||||
<div class="mb-4">
|
||||
<p class="text-gray-600 mb-4">You are currently subscribed to <span class="font-bold text-gray-900">{{ currentPlan?.name }}</span>.</p>
|
||||
<div class="bg-gray-50 p-4 rounded-lg space-y-2 border border-gray-200">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Status</span>
|
||||
<span class="text-sm font-medium text-green-600">Active</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Renewal Date</span>
|
||||
<span class="text-sm font-medium text-gray-900">Feb 24, 2026</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-sm text-gray-500">Amount</span>
|
||||
<span class="text-sm font-medium text-gray-900">${{ currentPlan?.price || 0 }}/mo</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 mb-6">
|
||||
Canceling your subscription will downgrade you to the Free plan at the end of your current billing period.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button label="Close" text severity="secondary" @click="visibleModel = false" />
|
||||
<Button
|
||||
label="Cancel Subscription"
|
||||
severity="danger"
|
||||
:icon="cancelling ? 'i-svg-spinners-180-ring-with-bg' : 'i-heroicons-x-circle'"
|
||||
@click="emit('cancel-subscription')"
|
||||
:disabled="cancelling"
|
||||
/>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { type ModelPlan } from '@/api/client';
|
||||
import Button from 'primevue/button';
|
||||
import Skeleton from 'primevue/skeleton';
|
||||
import { formatBytes } from '@/lib/utils'; // Using utils formatBytes
|
||||
|
||||
defineProps<{
|
||||
plans: ModelPlan[];
|
||||
isLoading: boolean;
|
||||
currentPlanId?: string;
|
||||
subscribingPlanId?: string | null;
|
||||
isAdmin?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'subscribe', plan: ModelPlan): void;
|
||||
(e: 'edit', plan: ModelPlan): void;
|
||||
}>();
|
||||
|
||||
const formatDuration = (seconds?: number) => {
|
||||
if (!seconds) return '0 mins';
|
||||
return `${Math.floor(seconds / 60)} mins`;
|
||||
};
|
||||
|
||||
const isPopular = (plan: ModelPlan) => {
|
||||
return plan.name?.toLowerCase().includes('pro') || plan.name?.toLowerCase().includes('premium');
|
||||
};
|
||||
|
||||
const isCurrentComp = (plan: ModelPlan, currentId?: string) => {
|
||||
return plan.id === currentId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900">Upgrade your workspace</h2>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="isLoading" class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div v-for="i in 3" :key="i" class="h-full">
|
||||
<Skeleton height="300px" borderRadius="16px"></Skeleton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-3 gap-8 items-start">
|
||||
<div v-for="plan in plans" :key="plan.id" class="relative group h-full">
|
||||
<div v-if="isPopular(plan) && !isCurrentComp(plan, currentPlanId)" class="absolute -top-3 left-1/2 -translate-x-1/2 bg-primary text-white text-xs font-bold px-3 py-1 rounded-full z-10 shadow-md uppercase tracking-wide">
|
||||
Recommended
|
||||
</div>
|
||||
|
||||
<!-- Admin Edit Button -->
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
icon="i-heroicons-pencil-square"
|
||||
class="absolute top-2 right-2 z-20 !p-2 !w-8 !h-8"
|
||||
severity="secondary"
|
||||
text
|
||||
rounded
|
||||
@click.stop="emit('edit', plan)"
|
||||
/>
|
||||
|
||||
<div :class="[
|
||||
'relative bg-white rounded-2xl p-6 h-full border transition-all duration-200 flex flex-col',
|
||||
isCurrentComp(plan, currentPlanId) ? 'border-primary ring-1 ring-primary/50 bg-primary-50/10' : 'border-gray-200 hover:border-gray-300 hover:shadow-lg',
|
||||
isPopular(plan) && !isCurrentComp(plan, currentPlanId) ? 'shadow-md border-primary/20' : ''
|
||||
]">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-xl font-bold text-gray-900">{{ plan.name }}</h3>
|
||||
<p class="text-gray-500 text-sm min-h-[2.5rem] mt-2">{{ plan.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-4xl font-bold text-gray-900">${{ plan.price }}</span>
|
||||
<span class="text-gray-500 text-sm">/{{ plan.cycle }}</span>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-3 mb-8 flex-grow">
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatBytes(plan.storage_limit || 0) }} Storage
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ formatDuration(plan.duration_limit) }} Max Duration
|
||||
</li>
|
||||
<li class="flex items-center gap-3 text-sm text-gray-700">
|
||||
<span class="i-heroicons-check-circle text-green-500 text-lg flex-shrink-0"></span>
|
||||
{{ plan.upload_limit }} Uploads / day
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
:label="isCurrentComp(plan, currentPlanId) ? 'Current Plan' : (subscribingPlanId === plan.id ? 'Processing...' : 'Upgrade')"
|
||||
:icon="subscribingPlanId === plan.id ? 'i-svg-spinners-180-ring-with-bg' : ''"
|
||||
class="w-full"
|
||||
:severity="isCurrentComp(plan, currentPlanId) ? 'secondary' : 'primary'"
|
||||
:outlined="isCurrentComp(plan, currentPlanId)"
|
||||
:disabled="!!subscribingPlanId || isCurrentComp(plan, currentPlanId)"
|
||||
@click="emit('subscribe', plan)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,93 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button';
|
||||
import Column from 'primevue/column';
|
||||
import DataTable from 'primevue/datatable';
|
||||
import Tag from 'primevue/tag';
|
||||
|
||||
interface PaymentHistoryItem {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
plan: string;
|
||||
status: string;
|
||||
invoiceId: string;
|
||||
}
|
||||
|
||||
defineProps<{
|
||||
history: PaymentHistoryItem[];
|
||||
}>();
|
||||
|
||||
const getStatusSeverity = (status: string) => {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success';
|
||||
case 'failed':
|
||||
return 'danger';
|
||||
case 'pending':
|
||||
return 'warn';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
import ArrowDownTray from '@/components/icons/ArrowDownTray.vue';
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
|
||||
|
||||
const downloadInvoice = (item: PaymentHistoryItem) => {
|
||||
toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Downloading',
|
||||
detail: `Downloading invoice #${item.invoiceId}...`,
|
||||
life: 2000
|
||||
});
|
||||
|
||||
// Simulate download delay
|
||||
setTimeout(() => {
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Downloaded',
|
||||
detail: `Invoice #${item.invoiceId} downloaded successfully`,
|
||||
life: 3000
|
||||
});
|
||||
}, 1500);
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section>
|
||||
<h2 class="text-2xl font-bold mb-6 text-gray-900">Billing History</h2>
|
||||
<div class="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||
<DataTable :value="history" responsiveLayout="scroll" class="w-full">
|
||||
<template #empty>
|
||||
<div class="text-center py-8 text-gray-500">No payment history found.</div>
|
||||
</template>
|
||||
<Column field="date" header="Date" class="font-medium"></Column>
|
||||
<Column field="amount" header="Amount">
|
||||
<template #body="slotProps">
|
||||
${{ slotProps.data.amount }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="plan" header="Plan"></Column>
|
||||
<Column field="status" header="Status">
|
||||
<template #body="slotProps">
|
||||
<Tag :value="slotProps.data.status" :severity="getStatusSeverity(slotProps.data.status)"
|
||||
class="capitalize px-2 py-0.5 text-xs" :rounded="true" />
|
||||
</template>
|
||||
</Column>
|
||||
<!-- <Column header="" style="width: 3rem">
|
||||
<template #body="slotProps">
|
||||
<Button text rounded severity="secondary" size="small" @click="downloadInvoice(slotProps.data)"
|
||||
v-tooltip="'Download Invoice'">
|
||||
<template #icon>
|
||||
<ArrowDownTray class="w-5 h-5" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Column> -->
|
||||
</DataTable>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -1,39 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
uploadsUsed: number;
|
||||
uploadsLimit: number;
|
||||
}>();
|
||||
|
||||
const storagePercentage = computed(() => Math.min(Math.round((props.storageUsed / props.storageLimit) * 100), 100));
|
||||
const uploadsPercentage = computed(() => Math.min(Math.round((props.uploadsUsed / props.uploadsLimit) * 100), 100));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-8 flex flex-col justify-center">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-6">Usage Statistics</h3>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600 font-medium">Storage</span>
|
||||
<span class="text-gray-900 font-bold">{{ storagePercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 8px" :class="storagePercentage > 90 ? 'p-progressbar-danger' : ''"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600 font-medium">Monthly Uploads</span>
|
||||
<span class="text-gray-900 font-bold">{{ uploadsPercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="uploadsPercentage" :showValue="false" style="height: 8px"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ uploadsUsed }} of {{ uploadsLimit }} uploads</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,106 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import ProfileHero from './components/ProfileHero.vue';
|
||||
import ProfileInfoCard from './components/ProfileInfoCard.vue';
|
||||
import ChangePasswordDialog from './components/ChangePasswordDialog.vue';
|
||||
import AccountStatusCard from './components/AccountStatusCard.vue';
|
||||
import LinkedAccountsCard from './components/LinkedAccountsCard.vue';
|
||||
import { useToast } from 'primevue/usetoast';
|
||||
|
||||
const auth = useAuthStore();
|
||||
const toast = useToast();
|
||||
|
||||
// Dialog visibility
|
||||
const showPasswordDialog = ref(false);
|
||||
|
||||
// Refs for dialog components
|
||||
const passwordDialogRef = ref<InstanceType<typeof ChangePasswordDialog>>();
|
||||
|
||||
// Computed storage values
|
||||
const storageUsed = computed(() => auth.user?.storage_used || 0);
|
||||
const storageLimit = computed(() => 10737418240); // 10GB default
|
||||
|
||||
// Handlers
|
||||
const handleEditSave = async (data: { username: string; email: string }) => {
|
||||
try {
|
||||
await auth.updateProfile(data);
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Profile Updated',
|
||||
detail: 'Your profile has been updated successfully.',
|
||||
life: 3000
|
||||
});
|
||||
} catch (e) {
|
||||
toast.add({
|
||||
severity: 'error',
|
||||
summary: 'Update Failed',
|
||||
detail: auth.error || 'Failed to update profile.',
|
||||
life: 5000
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordSave = async (data: { currentPassword: string; newPassword: string }) => {
|
||||
try {
|
||||
await auth.changePassword(data.currentPassword, data.newPassword);
|
||||
showPasswordDialog.value = false;
|
||||
toast.add({
|
||||
severity: 'success',
|
||||
summary: 'Password Changed',
|
||||
detail: 'Your password has been changed successfully.',
|
||||
life: 3000
|
||||
});
|
||||
} catch (e: any) {
|
||||
passwordDialogRef.value?.setError(e.message || 'Failed to change password');
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="profile-page">
|
||||
<PageHeader
|
||||
title="Profile Settings"
|
||||
description="Manage your account information and preferences."
|
||||
:breadcrumbs="[
|
||||
{ label: 'Dashboard', to: '/' },
|
||||
{ label: 'Profile' }
|
||||
]"
|
||||
/>
|
||||
|
||||
<div class="max-w-5xl mx-auto space-y-8 pb-12">
|
||||
<!-- Hero Identity Card -->
|
||||
<ProfileHero
|
||||
:user="auth.user"
|
||||
@logout="auth.logout()"
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<!-- Personal Info -->
|
||||
<div class="md:col-span-2">
|
||||
<ProfileInfoCard
|
||||
:user="auth.user"
|
||||
@change-password="showPasswordDialog = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stats Side -->
|
||||
<div class="md:col-span-1 space-y-6">
|
||||
<AccountStatusCard
|
||||
:storage-used="storageUsed"
|
||||
:storage-limit="storageLimit"
|
||||
/>
|
||||
<LinkedAccountsCard />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dialogs -->
|
||||
<ChangePasswordDialog
|
||||
ref="passwordDialogRef"
|
||||
v-model:visible="showPasswordDialog"
|
||||
@save="handlePasswordSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,47 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
storageUsed: number;
|
||||
storageLimit: number;
|
||||
}>();
|
||||
|
||||
const storagePercentage = computed(() =>
|
||||
Math.min(Math.round((props.storageUsed / props.storageLimit) * 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-white border border-gray-200 rounded-2xl p-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Account Status</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="flex justify-between text-sm mb-2">
|
||||
<span class="text-gray-600">Storage Used</span>
|
||||
<span class="font-bold text-gray-900">{{ storagePercentage }}%</span>
|
||||
</div>
|
||||
<ProgressBar :value="storagePercentage" :showValue="false" style="height: 6px"></ProgressBar>
|
||||
<p class="text-xs text-gray-500 mt-2">{{ formatBytes(storageUsed) }} of {{ formatBytes(storageLimit) }} used</p>
|
||||
</div>
|
||||
<div class="bg-green-50 rounded-lg p-4 border border-green-100 flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 text-green-600 mt-0.5 shrink-0" 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>
|
||||
<h4 class="font-bold text-green-800 text-sm">Account Active</h4>
|
||||
<p class="text-green-600 text-xs mt-0.5">Your subscription is in good standing.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,102 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog';
|
||||
import InputText from 'primevue/inputtext';
|
||||
import Button from 'primevue/button';
|
||||
import Message from 'primevue/message';
|
||||
import { ref, computed, watch } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:visible': [value: boolean];
|
||||
save: [data: { currentPassword: string; newPassword: string }];
|
||||
}>();
|
||||
|
||||
const currentPassword = ref('');
|
||||
const newPassword = ref('');
|
||||
const confirmPassword = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref('');
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
currentPassword.value = '';
|
||||
newPassword.value = '';
|
||||
confirmPassword.value = '';
|
||||
error.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
const isValid = computed(() => {
|
||||
return currentPassword.value.length >= 1
|
||||
&& newPassword.value.length >= 6
|
||||
&& newPassword.value === confirmPassword.value;
|
||||
});
|
||||
|
||||
const passwordMismatch = computed(() => {
|
||||
return confirmPassword.value.length > 0 && newPassword.value !== confirmPassword.value;
|
||||
});
|
||||
|
||||
const passwordTooShort = computed(() => {
|
||||
return newPassword.value.length > 0 && newPassword.value.length < 6;
|
||||
});
|
||||
|
||||
const handleSave = () => {
|
||||
if (!isValid.value) return;
|
||||
loading.value = true;
|
||||
error.value = '';
|
||||
emit('save', {
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
emit('update:visible', false);
|
||||
};
|
||||
|
||||
// Expose methods for parent to control loading state
|
||||
defineExpose({
|
||||
setLoading: (val: boolean) => { loading.value = val; },
|
||||
setError: (msg: string) => { error.value = msg; loading.value = false; }
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog :visible="visible" @update:visible="emit('update:visible', $event)" modal header="Change Password"
|
||||
:style="{ width: '28rem' }" :closable="true" :draggable="false">
|
||||
<div class="space-y-6 pt-2">
|
||||
<Message v-if="error" severity="error" :closable="false">{{ error }}</Message>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="current-password" class="text-sm font-medium text-gray-700">Current Password</label>
|
||||
<InputText id="current-password" v-model="currentPassword" type="password" class="w-full"
|
||||
placeholder="Enter current password" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="new-password" class="text-sm font-medium text-gray-700">New Password</label>
|
||||
<InputText id="new-password" v-model="newPassword" type="password" class="w-full"
|
||||
placeholder="Enter new password (min 6 characters)"
|
||||
:class="{ 'p-invalid': passwordTooShort }" />
|
||||
<small v-if="passwordTooShort" class="text-red-500">Password must be at least 6 characters</small>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="confirm-password" class="text-sm font-medium text-gray-700">Confirm New Password</label>
|
||||
<InputText id="confirm-password" v-model="confirmPassword" type="password" class="w-full"
|
||||
placeholder="Confirm new password"
|
||||
:class="{ 'p-invalid': passwordMismatch }" />
|
||||
<small v-if="passwordMismatch" class="text-red-500">Passwords do not match</small>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<Button label="Cancel" severity="secondary" @click="handleClose" :disabled="loading" />
|
||||
<Button label="Change Password" @click="handleSave" :loading="loading" :disabled="!isValid" />
|
||||
</div>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -1,25 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import Tag from 'primevue/tag';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-6">
|
||||
<h3 class="text-lg font-bold text-gray-900 mb-4">Linked Accounts</h3>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between p-3 rounded-lg border border-gray-100 hover:border-gray-200 transition-colors">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-8 h-8 rounded-full bg-red-100 flex items-center justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 text-red-600" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
|
||||
<path fill="currentColor" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
|
||||
<path fill="currentColor" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
|
||||
<path fill="currentColor" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span class="font-medium text-gray-700">Google</span>
|
||||
</div>
|
||||
<Tag value="Connected" severity="success" class="text-xs px-2"></Tag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,83 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelUser } from '@/api/client';
|
||||
import Avatar from 'primevue/avatar';
|
||||
import Button from 'primevue/button';
|
||||
import Tag from 'primevue/tag';
|
||||
import { computed } from 'vue';
|
||||
|
||||
const props = defineProps<{
|
||||
user: ModelUser | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
logout: [];
|
||||
changePassword: [];
|
||||
}>();
|
||||
|
||||
const joinDate = computed(() => {
|
||||
return new Date(props.user?.created_at || Date.now()).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative overflow-hidden rounded-2xl bg-gradient-to-r from-gray-900 via-gray-800 to-gray-900 text-white p-8 md:p-10">
|
||||
<!-- Background decorations -->
|
||||
<div class="absolute top-0 right-0 -mt-20 -mr-20 w-80 h-80 bg-primary-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
|
||||
<div class="absolute bottom-0 left-0 -mb-20 -ml-20 w-80 h-80 bg-purple-500 rounded-full mix-blend-overlay filter blur-3xl"></div>
|
||||
|
||||
<div class="relative z-10 flex flex-col md:flex-row items-center gap-8">
|
||||
<div class="relative">
|
||||
<div class="absolute inset-0 bg-primary-500 rounded-full blur-lg opacity-40"></div>
|
||||
<!-- :label="user?.username?.charAt(0).toUpperCase() || 'U'" -->
|
||||
<Avatar
|
||||
class="relative border-4 border-gray-800 text-3xl font-bold bg-gradient-to-br from-primary-400 to-primary-600 text-white shadow-2xl"
|
||||
size="xlarge"
|
||||
shape="circle"
|
||||
style="width: 120px; height: 120px; font-size: 3rem;"
|
||||
image="https://picsum.photos/seed/user123/120/120.jpg"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-center md:text-left space-y-2 flex-grow">
|
||||
<div class="flex flex-col md:flex-row items-center gap-3 justify-center md:justify-start">
|
||||
<h2 class="text-3xl font-bold text-white">{{ user?.username || 'User' }}</h2>
|
||||
<Tag :value="user?.role || 'User'" severity="info" class="uppercase tracking-wider px-2 header-tag" rounded></Tag>
|
||||
</div>
|
||||
<p class="text-gray-400 text-lg">{{ user?.email }}</p>
|
||||
<p class="text-gray-500 text-sm flex items-center justify-center md:justify-start gap-2">
|
||||
<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" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="4" rx="2" ry="2"/>
|
||||
<line x1="16" x2="16" y1="2" y2="6"/>
|
||||
<line x1="8" x2="8" y1="2" y2="6"/>
|
||||
<line x1="3" x2="21" y1="10" y2="10"/>
|
||||
</svg>
|
||||
Member since {{ joinDate }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<Button label="Logout" severity="danger" class="border-white/10 text-white hover:bg-white/10 bg-white/5" @click="emit('logout')">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
|
||||
<polyline points="16 17 21 12 16 7"/>
|
||||
<line x1="21" x2="9" y1="12" y2="12"/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.header-tag) {
|
||||
background: rgba(255,255,255,0.2) !important;
|
||||
color: white !important;
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,81 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelUser } from '@/api/client';
|
||||
import Button from 'primevue/button';
|
||||
import InputText from 'primevue/inputtext';
|
||||
|
||||
defineProps<{
|
||||
user: ModelUser | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
edit: [];
|
||||
changePassword: [];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-white border border-gray-200 rounded-2xl p-8">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h3 class="text-xl font-bold text-gray-900">Personal Information</h3>
|
||||
<div class="flex gap-2">
|
||||
<Button label="Change Password" text severity="secondary" @click="emit('changePassword')">
|
||||
<template #icon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2"/>
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="username" class="text-sm font-medium text-gray-700">Username</label>
|
||||
<div class="relative">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/>
|
||||
<circle cx="12" cy="7" r="4"/>
|
||||
</svg>
|
||||
</InputIcon>
|
||||
<InputText id="username" :value="user?.username" class="w-full pl-10" readonly />
|
||||
</IconField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="email" class="text-sm font-medium text-gray-700">Email Address</label>
|
||||
<div class="relative">
|
||||
<IconField>
|
||||
<InputIcon>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" 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>
|
||||
</InputIcon>
|
||||
<InputText id="email" :value="user?.email" class="w-full pl-10" readonly />
|
||||
</IconField>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<!-- <div class="flex flex-col gap-2">
|
||||
<label for="role" class="text-sm font-medium text-gray-700">Role</label>
|
||||
<InputText id="role" :value="user?.role || 'User'" class="w-full capitalize bg-gray-50" readonly />
|
||||
</div> -->
|
||||
<!-- <div class="flex flex-col gap-2">
|
||||
<label for="id" class="text-sm font-medium text-gray-700">User ID</label>
|
||||
<InputText id="id" :value="user?.id || 'N/A'" class="w-full font-mono text-sm bg-gray-50" readonly />
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-inputtext[readonly]) {
|
||||
background-color: #f9fafb;
|
||||
border-color: #e5e7eb;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
175
src/routes/settings/Settings.vue
Normal file
175
src/routes/settings/Settings.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<section>
|
||||
<PageHeader
|
||||
:title="content[route.name as keyof typeof content]?.title || 'Settings'"
|
||||
:description="content[route.name as keyof typeof content]?.subtitle || 'Manage your account settings and preferences.'"
|
||||
:breadcrumbs="breadcrumbs"
|
||||
/>
|
||||
<div class="max-w-7xl mx-auto pb-12">
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 mt-6">
|
||||
<!-- Sidebar Navigation (GitHub-style) -->
|
||||
<aside class="md:w-56 shrink-0">
|
||||
<div class="flex items-center gap-4 mb-8">
|
||||
<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>
|
||||
<nav class="space-y-6">
|
||||
<div v-for="section in menuSections" :key="section.title">
|
||||
<h3 v-if="section.title" class="text-xs font-semibold text-foreground/50 uppercase tracking-wider mb-2 pl-3">
|
||||
{{ section.title }}
|
||||
</h3>
|
||||
<ul class="space-y-0.5">
|
||||
<li v-for="item in section.items" :key="item.value">
|
||||
<router-link
|
||||
:to="tabPaths[item.value]"
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-md text-sm font-medium transition-all duration-150',
|
||||
currentTab === item.value
|
||||
? 'bg-primary/10 text-primary font-semibold'
|
||||
: item.danger
|
||||
? 'text-danger hover:bg-danger/10'
|
||||
: 'text-foreground/70 hover:bg-muted hover:text-foreground'
|
||||
]"
|
||||
>
|
||||
<component :is="item.icon" class="w-5 h-5 shrink-0" :filled="currentTab === item.value" />
|
||||
{{ item.label }}
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<!-- Main Content Area -->
|
||||
<main class="flex-1 min-w-0">
|
||||
<router-view />
|
||||
|
||||
<!-- Settings-only toast/confirm hosts (no PrimeVue dependency) -->
|
||||
<ClientOnly>
|
||||
<AppToastHost />
|
||||
<AppConfirmHost />
|
||||
</ClientOnly>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import PageHeader from '@/components/dashboard/PageHeader.vue';
|
||||
import AppConfirmHost from '@/components/app/AppConfirmHost.vue';
|
||||
import AppToastHost from '@/components/app/AppToastHost.vue';
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import UserIcon from '@/components/icons/UserIcon.vue';
|
||||
import GlobeIcon from '@/components/icons/Globe.vue';
|
||||
import AlertTriangle from '@/components/icons/AlertTriangle.vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import CreditCardIcon from '@/components/icons/CreditCardIcon.vue';
|
||||
import Bell from '@/components/icons/Bell.vue';
|
||||
import AdvertisementIcon from '@/components/icons/AdvertisementIcon.vue';
|
||||
import VideoPlayIcon from '@/components/icons/VideoPlayIcon.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const auth = useAuthStore();
|
||||
// Map tab values to their paths
|
||||
const tabPaths: Record<string, string> = {
|
||||
profile: '/settings',
|
||||
security: '/settings/security',
|
||||
notifications: '/settings/notifications',
|
||||
player: '/settings/player',
|
||||
billing: '/settings/billing',
|
||||
domains: '/settings/domains',
|
||||
ads: '/settings/ads',
|
||||
danger: '/settings/danger',
|
||||
};
|
||||
|
||||
// Menu items grouped by category (GitHub-style)
|
||||
const menuSections: { title?: string; items: { value: string; label: string; icon: any; danger?: boolean }[] }[] = [
|
||||
{
|
||||
title: 'Security',
|
||||
items: [
|
||||
{ value: 'security', label: 'Security', icon: UserIcon },
|
||||
{ value: 'billing', label: 'Billing & Plans', icon: CreditCardIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Preferences',
|
||||
items: [
|
||||
{ value: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ value: 'player', label: 'Player', icon: VideoPlayIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Integrations',
|
||||
items: [
|
||||
{ value: 'domains', label: 'Allowed Domains', icon: GlobeIcon },
|
||||
{ value: 'ads', label: 'Ads & VAST', icon: AdvertisementIcon },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Danger Zone',
|
||||
items: [
|
||||
{ value: 'danger', label: 'Danger Zone', icon: AlertTriangle, danger: true },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
type TabValue = typeof menuSections[number]['items'][number]['value'];
|
||||
|
||||
// Get current tab from route path
|
||||
const currentTab = computed<TabValue>(() => {
|
||||
const path = route.path as string;
|
||||
const tabName = path.replace('/settings', '') || '/profile';
|
||||
if (tabName === '' || tabName === '/') return 'profile';
|
||||
return (tabName.replace('/', '') as TabValue) || 'profile';
|
||||
});
|
||||
|
||||
// Breadcrumbs with dynamic tab
|
||||
const allMenuItems = menuSections.flatMap(section => section.items);
|
||||
const currentItem = allMenuItems.find(item => item.value === currentTab.value);
|
||||
|
||||
const breadcrumbs = [
|
||||
{ label: 'Dashboard', to: '/overview' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
...(currentItem ? [{ label: currentItem.label }] : []),
|
||||
];
|
||||
|
||||
const content = {
|
||||
security: {
|
||||
title: 'Security & Connected Apps',
|
||||
subtitle: 'Manage your security settings and connected applications.'
|
||||
},
|
||||
notifications: {
|
||||
title: 'Notifications',
|
||||
subtitle: 'Choose how you want to receive notifications and updates.'
|
||||
},
|
||||
player: {
|
||||
title: 'Player Settings',
|
||||
subtitle: 'Configure default video player behavior and features.'
|
||||
},
|
||||
billing: {
|
||||
title: 'Billing & Plans',
|
||||
subtitle: 'Your current subscription and billing information.'
|
||||
},
|
||||
domains: {
|
||||
title: 'Allowed Domains',
|
||||
subtitle: 'Add domains to your whitelist to allow embedding content via iframe.'
|
||||
},
|
||||
ads: {
|
||||
title: 'Ads & VAST',
|
||||
subtitle: 'Create and manage VAST ad templates for your videos.'
|
||||
},
|
||||
danger: {
|
||||
title: 'Danger Zone',
|
||||
subtitle: 'Irreversible and destructive actions. Be careful!'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
191
src/routes/settings/components/ConnectedAccountsCard.vue
Normal file
191
src/routes/settings/components/ConnectedAccountsCard.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<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>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user