Compare commits
1 Commits
develop-i1
...
develop-ki
| Author | SHA1 | Date | |
|---|---|---|---|
| d0176fb48b |
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"permissions": {
|
|
||||||
"allow": [
|
|
||||||
"Bash(bun run build)",
|
|
||||||
"mcp__ide__getDiagnostics",
|
|
||||||
"Bash(bun install:*)",
|
|
||||||
"Bash(bun preview:*)",
|
|
||||||
"Bash(curl:*)"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
374
AGENTS.md
374
AGENTS.md
@@ -1,374 +0,0 @@
|
|||||||
# AGENTS.md
|
|
||||||
|
|
||||||
This file provides guidance for AI coding agents working with the Holistream codebase.
|
|
||||||
hallo
|
|
||||||
## 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.*
|
|
||||||
83
CLAUDE.md
83
CLAUDE.md
@@ -1,83 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project overview
|
|
||||||
|
|
||||||
`stream-ui` is a Vue 3 SSR frontend deployed on Cloudflare Workers. It uses Hono as the Worker server layer and a custom Vite SSR setup rather than Nuxt.
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
Run all commands from `stream-ui/`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Install dependencies
|
|
||||||
bun install
|
|
||||||
|
|
||||||
# Start local dev server
|
|
||||||
bun run dev
|
|
||||||
|
|
||||||
# Build client + worker bundles
|
|
||||||
bun run build
|
|
||||||
|
|
||||||
# Preview production build locally
|
|
||||||
bun run preview
|
|
||||||
|
|
||||||
# Deploy to Cloudflare Workers
|
|
||||||
bun run deploy
|
|
||||||
|
|
||||||
# Regenerate Cloudflare binding types from Wrangler config
|
|
||||||
bun run cf-typegen
|
|
||||||
|
|
||||||
# Tail Cloudflare Worker logs
|
|
||||||
bun run tail
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- This project uses Bun (`bun.lock` is present).
|
|
||||||
- There is currently no configured `test` script.
|
|
||||||
- There is currently no configured `lint` script.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### SSR entrypoints
|
|
||||||
- `src/index.tsx`: Hono Worker entry; registers middleware, proxy routes, merge/display/manifest routes, then SSR routes
|
|
||||||
- `src/main.ts`: shared app factory for SSR and client hydration
|
|
||||||
- `src/client.ts`: client-side hydration entry
|
|
||||||
- `ssrPlugin.ts`: custom Vite SSR plugin that builds the client first, injects the Vite manifest, and swaps environment-specific modules
|
|
||||||
|
|
||||||
### Routing and app structure
|
|
||||||
- Routes live in `src/routes/index.ts`.
|
|
||||||
- Routing is SSR-aware: `createMemoryHistory()` on the server and `createWebHistory()` in the browser.
|
|
||||||
- The app is split into:
|
|
||||||
- public pages
|
|
||||||
- auth pages
|
|
||||||
- protected dashboard/settings pages
|
|
||||||
- Current protected areas include `videos`, `notification`, and `settings/*` routes.
|
|
||||||
|
|
||||||
### State and hydration
|
|
||||||
- Pinia is used for app state.
|
|
||||||
- `@pinia/colada` is used for server-state/query hydration.
|
|
||||||
- SSR serializes Pinia state into `$p` and query cache into `$colada`; `src/client.ts` restores both during hydration.
|
|
||||||
- `src/stores/auth.ts` owns session state and route guards depend on `auth.user`.
|
|
||||||
|
|
||||||
### API integration
|
|
||||||
- `src/api/client.ts` is generated by `swagger-typescript-api`; do not hand-edit generated sections.
|
|
||||||
- API access should go through the generated client and `@httpClientAdapter`, not raw `fetch`.
|
|
||||||
- `src/api/httpClientAdapter.server.ts` handles SSR-side API calls by forwarding request headers/cookies and proxying frontend `/r/*` requests to `https://api.pipic.fun`.
|
|
||||||
- `src/api/httpClientAdapter.client.ts` is the browser-side adapter.
|
|
||||||
|
|
||||||
### Notable flows
|
|
||||||
- `src/stores/auth.ts` initializes the logged-in user from `/me` and opens an MQTT connection after login.
|
|
||||||
- `src/composables/useUploadQueue.ts` implements the custom upload queue:
|
|
||||||
- 90MB chunks
|
|
||||||
- max 3 parallel uploads
|
|
||||||
- max 3 retries
|
|
||||||
- max 5 queued items
|
|
||||||
- Styling uses UnoCSS (`uno.config.ts`).
|
|
||||||
|
|
||||||
## Important notes
|
|
||||||
|
|
||||||
- Prefer the actual current code over older documentation when they conflict.
|
|
||||||
- The previous version of this file contained stale route and dependency details; verify against `src/routes/index.ts` and `package.json` before assuming old pages or libraries still exist.
|
|
||||||
- Any frontend change that affects API contracts should be checked against the backend repo (`../stream.api`) as well.
|
|
||||||
108
components.d.ts
vendored
108
components.d.ts
vendored
@@ -12,159 +12,111 @@ export {}
|
|||||||
/* prettier-ignore */
|
/* prettier-ignore */
|
||||||
declare module 'vue' {
|
declare module 'vue' {
|
||||||
export interface GlobalComponents {
|
export interface GlobalComponents {
|
||||||
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
|
||||||
Add: typeof import('./src/components/icons/Add.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']
|
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']
|
|
||||||
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
|
||||||
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
Avatar: typeof import('./src/components/ui/Avatar.vue')['default']
|
||||||
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
Button: typeof import('./src/components/ui/Button.vue')['default']
|
||||||
|
Card: typeof import('./src/components/ui/Card.vue')['default']
|
||||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||||
|
Checkbox: typeof import('./src/components/ui/Checkbox.vue')['default']
|
||||||
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['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']
|
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||||
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
DataTable: typeof import('./src/components/table/DataTable.vue')['default']
|
||||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
Dialog: typeof import('./src/components/ui/Dialog.vue')['default']
|
||||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
Field: typeof import('./src/components/form/Field.vue')['default']
|
||||||
|
Form: typeof import('./src/components/form/Form.vue')['default']
|
||||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.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']
|
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']
|
Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||||
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
|
||||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||||
|
Input: typeof import('./src/components/ui/Input.vue')['default']
|
||||||
|
InputPassword: typeof import('./src/components/ui/InputPassword.vue')['default']
|
||||||
Layout: typeof import('./src/components/icons/Layout.vue')['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']
|
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
Message: typeof import('./src/components/form/Message.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']
|
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
ProgressBar: typeof import('./src/components/ui/ProgressBar.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']
|
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
RouterLink: typeof import('vue-router')['RouterLink']
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
RouterView: typeof import('vue-router')['RouterView']
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
|
||||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||||
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
Skeleton: typeof import('./src/components/ui/Skeleton.vue')['default']
|
||||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
Tag: typeof import('./src/components/ui/Tag.vue')['default']
|
||||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
Toast: typeof import('./src/components/ui/Toast.vue')['default']
|
||||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
Upload: typeof import('./src/components/icons/Upload.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']
|
Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.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']
|
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']
|
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||||
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For TSX support
|
// For TSX support
|
||||||
declare global {
|
declare global {
|
||||||
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
|
||||||
const Add: typeof import('./src/components/icons/Add.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 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 AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
|
|
||||||
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
|
||||||
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
|
||||||
|
const Avatar: typeof import('./src/components/ui/Avatar.vue')['default']
|
||||||
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
|
||||||
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
|
const Button: typeof import('./src/components/ui/Button.vue')['default']
|
||||||
|
const Card: typeof import('./src/components/ui/Card.vue')['default']
|
||||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||||
|
const Checkbox: typeof import('./src/components/ui/Checkbox.vue')['default']
|
||||||
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['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 Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||||
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||||
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
const DataTable: typeof import('./src/components/table/DataTable.vue')['default']
|
||||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
const Dialog: typeof import('./src/components/ui/Dialog.vue')['default']
|
||||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||||
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
const Field: typeof import('./src/components/form/Field.vue')['default']
|
||||||
|
const Form: typeof import('./src/components/form/Form.vue')['default']
|
||||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.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 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 Home: typeof import('./src/components/icons/Home.vue')['default']
|
||||||
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
|
||||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||||
|
const Input: typeof import('./src/components/ui/Input.vue')['default']
|
||||||
|
const InputPassword: typeof import('./src/components/ui/InputPassword.vue')['default']
|
||||||
const Layout: typeof import('./src/components/icons/Layout.vue')['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 LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||||
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
const Message: typeof import('./src/components/form/Message.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 NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
|
||||||
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
|
||||||
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
|
||||||
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
|
const ProgressBar: typeof import('./src/components/ui/ProgressBar.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 RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
const RouterView: typeof import('vue-router')['RouterView']
|
const RouterView: typeof import('vue-router')['RouterView']
|
||||||
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
|
||||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||||
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
const Skeleton: typeof import('./src/components/ui/Skeleton.vue')['default']
|
||||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||||
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
const Tag: typeof import('./src/components/ui/Tag.vue')['default']
|
||||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
const Toast: typeof import('./src/components/ui/Toast.vue')['default']
|
||||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||||
const Upload: typeof import('./src/components/icons/Upload.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 Video: typeof import('./src/components/icons/Video.vue')['default']
|
||||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.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 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 XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||||
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
|
||||||
}
|
}
|
||||||
BIN
golang.tar.gz
BIN
golang.tar.gz
Binary file not shown.
48
package.json
48
package.json
@@ -2,42 +2,42 @@
|
|||||||
"name": "holistream",
|
"name": "holistream",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "bun vite",
|
"dev": "vite",
|
||||||
"build": "bun vite build",
|
"build": "vite build",
|
||||||
"preview": "bun vite preview",
|
"preview": "vite preview",
|
||||||
"deploy": "wrangler deploy",
|
"deploy": "wrangler deploy",
|
||||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
|
||||||
"tail": "wrangler tail"
|
"tail": "wrangler tail"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hattip/adapter-node": "^0.0.49",
|
"@aws-sdk/client-s3": "^3.971.0",
|
||||||
"@hono/node-server": "^1.19.11",
|
"@aws-sdk/s3-presigned-post": "^3.971.0",
|
||||||
"@pinia/colada": "^0.21.7",
|
"@aws-sdk/s3-request-presigner": "^3.971.0",
|
||||||
"@unhead/vue": "^2.1.10",
|
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@hiogawa/utils": "^1.7.0",
|
||||||
"aws4fetch": "^1.0.20",
|
"@tanstack/vue-form": "^1.28.0",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@unhead/vue": "^2.1.2",
|
||||||
|
"@vueuse/core": "^14.1.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.5",
|
"hono": "^4.11.4",
|
||||||
"i18next": "^25.8.14",
|
|
||||||
"i18next-http-backend": "^3.0.2",
|
|
||||||
"i18next-vue": "^5.4.0",
|
|
||||||
"is-mobile": "^5.0.0",
|
"is-mobile": "^5.0.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.4.0",
|
||||||
"vue": "^3.5.29",
|
"vue": "^3.5.27",
|
||||||
"vue-router": "^5.0.3",
|
"vue-router": "^5.0.2",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.26.0",
|
"@cloudflare/vite-plugin": "^1.21.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.0.9",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.3",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||||
"unocss": "^66.6.5",
|
"unocss": "^66.6.0",
|
||||||
"unplugin-auto-import": "^21.0.0",
|
"unplugin-auto-import": "^21.0.0",
|
||||||
"unplugin-vue-components": "^31.0.0",
|
"unplugin-vue-components": "^31.0.0",
|
||||||
"vite": "^8.0.0-beta.16",
|
"vite": "^7.3.1",
|
||||||
"vite-ssr-components": "^0.5.2",
|
"vite-ssr-components": "^0.5.2",
|
||||||
"wrangler": "^4.70.0"
|
"wrangler": "^4.59.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1376
src/api/client.ts
1376
src/api/client.ts
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
export const customFetch: typeof fetch = (input, init) => {
|
export const customFetch = (url: string, options: RequestInit) => {
|
||||||
return fetch(input, {
|
return fetch(url, {
|
||||||
...init,
|
...options,
|
||||||
credentials: 'include',
|
credentials: "include",
|
||||||
});
|
});
|
||||||
};
|
}
|
||||||
@@ -1,125 +1,31 @@
|
|||||||
import { tryGetContext } from 'hono/context-storage';
|
import { tryGetContext } from "hono/context-storage";
|
||||||
|
|
||||||
// export const baseAPIURL = 'https://api.pipic.fun';
|
export const customFetch = (url: string, options: RequestInit) => {
|
||||||
export const baseAPIURL = 'http://localhost:8080';
|
options.credentials = "include";
|
||||||
|
|
||||||
type RequestOptions = RequestInit | { raw: Request };
|
|
||||||
|
|
||||||
const isRequest = (input: URL | RequestInfo): input is Request =>
|
|
||||||
typeof Request !== 'undefined' && input instanceof Request;
|
|
||||||
|
|
||||||
const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } =>
|
|
||||||
typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request;
|
|
||||||
|
|
||||||
const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
|
|
||||||
if (input instanceof URL) return new URL(input.toString());
|
|
||||||
if (isRequest(input)) return new URL(input.url);
|
|
||||||
|
|
||||||
const baseUrl = new URL(currentRequestUrl);
|
|
||||||
baseUrl.pathname = '/';
|
|
||||||
baseUrl.search = '';
|
|
||||||
baseUrl.hash = '';
|
|
||||||
|
|
||||||
return new URL(input, baseUrl);
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
|
|
||||||
const inputUrl = resolveInputUrl(input, currentRequestUrl);
|
|
||||||
const apiUrl = new URL(baseAPIURL);
|
|
||||||
|
|
||||||
apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/';
|
|
||||||
apiUrl.search = inputUrl.search;
|
|
||||||
apiUrl.hash = inputUrl.hash;
|
|
||||||
|
|
||||||
return apiUrl;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getOptionHeaders = (options: RequestOptions) =>
|
|
||||||
isRequestLikeOptions(options) ? options.raw.headers : options.headers;
|
|
||||||
|
|
||||||
const getOptionMethod = (options: RequestOptions) =>
|
|
||||||
isRequestLikeOptions(options) ? options.raw.method : options.method;
|
|
||||||
|
|
||||||
const getOptionBody = (options: RequestOptions) =>
|
|
||||||
isRequestLikeOptions(options) ? options.raw.body : options.body;
|
|
||||||
|
|
||||||
const getOptionSignal = (options: RequestOptions) =>
|
|
||||||
isRequestLikeOptions(options) ? options.raw.signal : options.signal;
|
|
||||||
|
|
||||||
const getOptionCredentials = (options: RequestOptions) =>
|
|
||||||
isRequestLikeOptions(options) ? undefined : options.credentials;
|
|
||||||
|
|
||||||
const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => {
|
|
||||||
const c = tryGetContext<any>();
|
|
||||||
const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined);
|
|
||||||
const inputHeaders = isRequest(input) ? input.headers : undefined;
|
|
||||||
const optionHeaders = getOptionHeaders(options);
|
|
||||||
|
|
||||||
new Headers(inputHeaders).forEach((value, key) => {
|
|
||||||
mergedHeaders.set(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
new Headers(optionHeaders).forEach((value, key) => {
|
|
||||||
mergedHeaders.set(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
mergedHeaders.delete('host');
|
|
||||||
mergedHeaders.delete('connection');
|
|
||||||
mergedHeaders.delete('content-length');
|
|
||||||
mergedHeaders.delete('transfer-encoding');
|
|
||||||
|
|
||||||
return mergedHeaders;
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => {
|
|
||||||
const method = getOptionMethod(options);
|
|
||||||
if (method) return method;
|
|
||||||
if (isRequest(input)) return input.method;
|
|
||||||
return 'GET';
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => {
|
|
||||||
if (method === 'GET' || method === 'HEAD') return undefined;
|
|
||||||
|
|
||||||
const body = getOptionBody(options);
|
|
||||||
if (typeof body !== 'undefined') return body;
|
|
||||||
if (isRequest(input)) return input.body;
|
|
||||||
return undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => {
|
|
||||||
const c = tryGetContext<any>();
|
const c = tryGetContext<any>();
|
||||||
if (!c) {
|
if (!c) {
|
||||||
throw new Error('Hono context not found in SSR');
|
throw new Error("Hono context not found in SSR");
|
||||||
}
|
}
|
||||||
|
// Merge headers properly - keep original options.headers and add request headers
|
||||||
|
const reqHeaders = new Headers(c.req.header());
|
||||||
|
// Remove headers that shouldn't be forwarded
|
||||||
|
reqHeaders.delete("host");
|
||||||
|
reqHeaders.delete("connection");
|
||||||
|
|
||||||
const apiUrl = resolveApiUrl(input, c.req.url);
|
const mergedHeaders: Record<string, string> = {};
|
||||||
const method = resolveMethod(input, options);
|
reqHeaders.forEach((value, key) => {
|
||||||
const body = resolveBody(input, options, method.toUpperCase());
|
mergedHeaders[key] = value;
|
||||||
const requestOptions: RequestInit & { duplex?: 'half' } = {
|
});
|
||||||
...(isRequestLikeOptions(options) ? {} : options),
|
options.headers = {
|
||||||
method,
|
...mergedHeaders,
|
||||||
headers: mergeHeaders(input, options),
|
...(options.headers as Record<string, string>),
|
||||||
body,
|
|
||||||
credentials: getOptionCredentials(options) ?? 'include',
|
|
||||||
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (body) {
|
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
|
||||||
requestOptions.duplex = 'half';
|
return fetch(apiUrl, options).then(async (res) => {
|
||||||
}
|
res.headers.getSetCookie()?.forEach((cookie) => {
|
||||||
|
c.header("Set-Cookie", cookie);
|
||||||
return fetch(apiUrl, requestOptions).then((response) => {
|
});
|
||||||
const setCookies = typeof response.headers.getSetCookie === 'function'
|
return res;
|
||||||
? response.headers.getSetCookie()
|
|
||||||
: response.headers.get('set-cookie')
|
|
||||||
? [response.headers.get('set-cookie')!]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
for (const cookie of setCookies) {
|
|
||||||
c.header('Set-Cookie', cookie, { append: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
22
src/api/rpc/auth.ts
Normal file
22
src/api/rpc/auth.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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
src/api/rpc/commom.ts
Normal file
1
src/api/rpc/commom.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export const secret = "123_it-is-very-secret_123";
|
||||||
344
src/api/rpc/index.ts
Normal file
344
src/api/rpc/index.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
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();
|
||||||
|
};
|
||||||
198
src/api/rpc/s3_handle.ts
Normal file
198
src/api/rpc/s3_handle.ts
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
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,22 +1,11 @@
|
|||||||
import { hydrateQueryCache } from '@pinia/colada';
|
|
||||||
import 'uno.css';
|
|
||||||
import PiniaSharedState from './lib/PiniaSharedState';
|
|
||||||
import { createApp } from './main';
|
import { createApp } from './main';
|
||||||
|
import 'uno.css';
|
||||||
const readAppData = () => {
|
|
||||||
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
async function render() {
|
async function render() {
|
||||||
const appData = readAppData();
|
const { app, router } = createApp();
|
||||||
const { app, router, queryCache, pinia } = await createApp(appData.$locale);
|
router.isReady().then(() => {
|
||||||
pinia.use(PiniaSharedState({ enable: true, initialize: true }));
|
app.mount('body', true)
|
||||||
hydrateQueryCache(queryCache, appData.$colada || {});
|
})
|
||||||
|
|
||||||
await router.isReady();
|
|
||||||
app.mount('body', true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render().catch((error) => {
|
render().catch((error) => {
|
||||||
console.error('Error during app initialization:', error);
|
console.error('Error during app initialization:', error)
|
||||||
});
|
})
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { useRouteLoading } from '@/composables/useRouteLoading'
|
|
||||||
|
|
||||||
const { visible, progress } = useRouteLoading()
|
|
||||||
|
|
||||||
const barStyle = computed(() => ({
|
|
||||||
transform: `scaleX(${progress.value / 100})`,
|
|
||||||
opacity: visible.value ? '1' : '0',
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="pointer-events-none fixed inset-x-0 top-0 z-[9999] h-0.75"
|
|
||||||
aria-hidden="true"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="h-full origin-left rounded-r-full bg-primary/50 shadow-[0_0_12px_var(--colors-primary-DEFAULT)] transition-[transform,opacity] duration-200 ease-out"
|
|
||||||
:style="barStyle"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import DashboardNav from "./DashboardNav.vue";
|
import DashboardNav from "./DashboardNav.vue";
|
||||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||||
import Upload from "@/routes/upload/Upload.vue";
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -21,6 +20,5 @@ import Upload from "@/routes/upload/Upload.vue";
|
|||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
<GlobalUploadIndicator />
|
<GlobalUploadIndicator />
|
||||||
<Upload />
|
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,53 +1,56 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import Bell from "@/components/icons/Bell.vue";
|
import Bell from "@/components/icons/Bell.vue";
|
||||||
|
import Credit from "@/components/icons/Credit.vue";
|
||||||
import Home from "@/components/icons/Home.vue";
|
import Home from "@/components/icons/Home.vue";
|
||||||
|
import Upload from "@/components/icons/Upload.vue";
|
||||||
import Video from "@/components/icons/Video.vue";
|
import Video from "@/components/icons/Video.vue";
|
||||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
|
||||||
// import Upload from "@/components/icons/Upload.vue";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { computed, createStaticVNode, ref } from "vue";
|
import { createStaticVNode, ref } from "vue";
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||||
|
|
||||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||||
|
const 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 notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||||
const isNotificationOpen = ref(false);
|
const isNotificationOpen = ref(false);
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const handleNotificationClick = (event: Event) => {
|
const handleNotificationClick = (event: Event) => {
|
||||||
notificationPopover.value?.toggle(event);
|
notificationPopover.value?.toggle(event);
|
||||||
};
|
};
|
||||||
|
|
||||||
const links = computed(() => [
|
const links = [
|
||||||
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
|
||||||
{ href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
|
{ href: "/", label: "Overview", icon: Home, type: "a", className },
|
||||||
// { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
|
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
|
||||||
{ href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
|
{ href: "/video", label: "Video", icon: Video, type: "a", className },
|
||||||
{ href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
|
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
|
||||||
{ href: "/settings", label: t('nav.settings'), icon: SettingsIcon, 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' },
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
//v-tooltip="i.label"
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header
|
<header
|
||||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
|
||||||
|
|
||||||
<template v-for="i in links" :key="i.href">
|
<template v-for="i in links" :key="i.label">
|
||||||
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
|
||||||
v-bind="i.type === 'a' ? { to: i.href } : {}"
|
v-bind="i.type === 'a' ? { to: i.href } : {}" :title="i.label" @click="i.action && i.action($event)"
|
||||||
@click="i.action && i.action($event)"
|
|
||||||
:class="cn(
|
:class="cn(
|
||||||
i.className,
|
i.className,
|
||||||
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
|
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
|
||||||
)">
|
)">
|
||||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||||
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
|
:filled="$route.path === i.href || i.isActive?.value" />
|
||||||
</component>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
</header>
|
</header>
|
||||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
<ClientOnly>
|
||||||
|
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
||||||
|
</ClientOnly>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,154 +1,103 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
|
||||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
|
||||||
import { useUIState } from '@/stores/uiState';
|
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
|
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||||
|
|
||||||
|
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
|
||||||
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
|
|
||||||
const uiState = useUIState();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const isCollapsed = ref(false);
|
const isOpen = ref(false);
|
||||||
|
|
||||||
const isVisible = computed(() => items.value.length > 0);
|
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 overallProgress = computed(() => {
|
const progress = computed(() => {
|
||||||
if (items.value.length === 0) return 0;
|
if (items.value.length === 0) return 0;
|
||||||
const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||||
return Math.round(total / items.value.length);
|
return Math.round(totalProgress / items.value.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
const isUploading = computed(() =>
|
const isUploading = computed(() => {
|
||||||
items.value.some(i => i.status === 'uploading' || i.status === 'fetching' || i.status === 'processing')
|
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
|
||||||
);
|
|
||||||
|
|
||||||
const isAllDone = computed(() =>
|
|
||||||
items.value.length > 0 && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
|
||||||
);
|
|
||||||
|
|
||||||
const statusText = computed(() => {
|
|
||||||
if (isAllDone.value) return t('upload.indicator.allDone');
|
|
||||||
if (isUploading.value) {
|
|
||||||
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
|
|
||||||
return t('upload.indicator.uploading', { count });
|
|
||||||
}
|
|
||||||
if (pendingCount.value > 0) return t('upload.indicator.waiting', { count: pendingCount.value });
|
|
||||||
return t('upload.queueItem.status.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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 translate-y-4"
|
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
|
||||||
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">
|
|
||||||
|
|
||||||
<div v-if="isVisible"
|
<!-- Mini Queue Popover -->
|
||||||
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"
|
<Transition enter-active-class="transition duration-200 ease-out"
|
||||||
style="max-height: 540px;">
|
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>
|
||||||
|
|
||||||
<!-- Header bar -->
|
<div
|
||||||
<div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
|
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">
|
||||||
@click="isCollapsed = !isCollapsed">
|
<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>
|
||||||
|
|
||||||
<!-- Status icon -->
|
<!-- Floating Button -->
|
||||||
<div class="relative w-6 h-6 shrink-0">
|
<button @click="toggleOpen"
|
||||||
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
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">
|
||||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
<!-- Progress Ring -->
|
||||||
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
<div class="relative w-10 h-10 flex items-center justify-center">
|
||||||
</svg>
|
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
|
||||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
<path class="stroke-current" fill="none" stroke-width="3"
|
||||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="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>
|
||||||
|
<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">
|
||||||
<path d="M20 6 9 17l-5-5" />
|
<path d="M20 6 9 17l-5-5" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else class="w-6 h-6 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
|
||||||
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">
|
|
||||||
{{ t('upload.indicator.completeProgress', { complete: completeCount, total: items.length }) }}
|
|
||||||
</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>
|
|
||||||
{{ t('upload.indicator.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">
|
|
||||||
{{ t('upload.indicator.viewVideos') }}
|
|
||||||
</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="t('upload.indicator.addMoreFiles')">
|
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Overall progress bar -->
|
<div class="text-left">
|
||||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
|
||||||
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
|
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-slate-500">
|
||||||
|
{{ completeCount }} / {{ items.length }} files
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- File list -->
|
<div v-if="pendingCount"
|
||||||
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
|
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">
|
||||||
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
|
{{ pendingCount }}
|
||||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
</div>
|
||||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
</button>
|
||||||
<div class="p-3 flex flex-col gap-2">
|
</div>
|
||||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
|
|
||||||
@cancel="cancelItem($event)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,51 +1,116 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||||
import { useNotifications } from '@/composables/useNotifications';
|
|
||||||
import { onClickOutside } from '@vueuse/core';
|
import { onClickOutside } from '@vueuse/core';
|
||||||
import { computed, onMounted, ref, watch } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
const isMounted = ref(false);
|
|
||||||
onMounted(() => {
|
|
||||||
isMounted.value = true;
|
|
||||||
void notificationStore.fetchNotifications();
|
|
||||||
});
|
|
||||||
|
|
||||||
|
// Emit event when visibility changes
|
||||||
const emit = defineEmits(['change']);
|
const emit = defineEmits(['change']);
|
||||||
|
|
||||||
|
type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
||||||
|
|
||||||
|
interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: NotificationType;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
read: boolean;
|
||||||
|
actionUrl?: string;
|
||||||
|
actionLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
const drawerRef = ref(null);
|
const drawerRef = ref(null);
|
||||||
const { t } = useTranslation();
|
|
||||||
const notificationStore = useNotifications();
|
|
||||||
|
|
||||||
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
// Mock notifications data
|
||||||
const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
|
const notifications = ref<Notification[]>([
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
type: 'video',
|
||||||
|
title: 'Video processing complete',
|
||||||
|
message: 'Your video "Summer Vacation 2024" has been successfully processed.',
|
||||||
|
time: '2 min ago',
|
||||||
|
read: false,
|
||||||
|
actionUrl: '/video',
|
||||||
|
actionLabel: 'View'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
type: 'payment',
|
||||||
|
title: 'Payment successful',
|
||||||
|
message: 'Your subscription to Pro Plan has been renewed successfully.',
|
||||||
|
time: '1 hour ago',
|
||||||
|
read: false,
|
||||||
|
actionUrl: '/payments-and-plans',
|
||||||
|
actionLabel: 'Receipt'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '3',
|
||||||
|
type: 'warning',
|
||||||
|
title: 'Storage almost full',
|
||||||
|
message: 'You have used 85% of your storage quota.',
|
||||||
|
time: '3 hours ago',
|
||||||
|
read: false,
|
||||||
|
actionUrl: '/payments-and-plans',
|
||||||
|
actionLabel: 'Upgrade'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '4',
|
||||||
|
type: 'success',
|
||||||
|
title: 'Upload successful',
|
||||||
|
message: 'Your video "Product Demo v2" has been uploaded.',
|
||||||
|
time: '1 day ago',
|
||||||
|
read: true
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
const unreadCount = computed(() => notifications.value.filter(n => !n.read).length);
|
||||||
|
|
||||||
const toggle = (event?: Event) => {
|
const toggle = (event?: Event) => {
|
||||||
console.log(event);
|
console.log(event);
|
||||||
visible.value = !visible.value;
|
// Prevent event propagation to avoid immediate closure by onClickOutside
|
||||||
if (visible.value && !notificationStore.loaded.value) {
|
if (event) {
|
||||||
void notificationStore.fetchNotifications();
|
// We don't stop propagation here to let other listeners work,
|
||||||
|
// but we might need to ignore the trigger element in onClickOutside
|
||||||
|
// However, since the trigger is outside this component, simple toggle logic works
|
||||||
|
// if we use a small delay or ignore ref.
|
||||||
|
// Best approach: "toggle" usually comes from a button click.
|
||||||
}
|
}
|
||||||
|
visible.value = !visible.value;
|
||||||
|
console.log(visible.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
onClickOutside(drawerRef, () => {
|
// Handle click outside
|
||||||
|
onClickOutside(drawerRef, (event) => {
|
||||||
|
// We can just set visible to false.
|
||||||
|
// Note: If the toggle button is clicked, it might toggle it back on immediately
|
||||||
|
// if the click event propagates.
|
||||||
|
// The user calls `toggle` from the parent's button click handler.
|
||||||
|
// If that button is outside `drawerRef` (which it is), this will fire.
|
||||||
|
// To avoid conflict, we usually check if the target is the trigger.
|
||||||
|
// But we don't have access to the trigger ref here.
|
||||||
|
// A common workaround is to use `ignore` option if we had the ref,
|
||||||
|
// or relying on the fact that if this fires, it sets specific state to false.
|
||||||
|
// If the button click then fires `toggle`, it might set it true again.
|
||||||
|
// Optimization: check if visible is true before closing.
|
||||||
if (visible.value) {
|
if (visible.value) {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
ignore: ['[name="Notification"]']
|
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleMarkRead = async (id: string) => {
|
const handleMarkRead = (id: string) => {
|
||||||
await notificationStore.markRead(id);
|
const notification = notifications.value.find(n => n.id === id);
|
||||||
|
if (notification) notification.read = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = (id: string) => {
|
||||||
await notificationStore.deleteNotification(id);
|
notifications.value = notifications.value.filter(n => n.id !== id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkAllRead = async () => {
|
const handleMarkAllRead = () => {
|
||||||
await notificationStore.markAllRead();
|
notifications.value.forEach(n => n.read = true);
|
||||||
};
|
};
|
||||||
|
|
||||||
watch(visible, (val) => {
|
watch(visible, (val) => {
|
||||||
@@ -56,16 +121,17 @@ defineExpose({ toggle });
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport v-if="isMounted" to="body">
|
<Teleport to="body">
|
||||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
<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"
|
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"
|
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
|
||||||
leave-to-class="opacity-0 -translate-x-4">
|
leave-to-class="opacity-0 -translate-x-4">
|
||||||
<div v-if="visible" ref="drawerRef"
|
<div v-if="visible" ref="drawerRef"
|
||||||
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
||||||
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between p-4">
|
<div class="flex items-center justify-between p-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
|
<h3 class="font-semibold text-gray-900">Notifications</h3>
|
||||||
<span v-if="unreadCount > 0"
|
<span v-if="unreadCount > 0"
|
||||||
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
|
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
|
||||||
{{ unreadCount }}
|
{{ unreadCount }}
|
||||||
@@ -73,44 +139,49 @@ defineExpose({ toggle });
|
|||||||
</div>
|
</div>
|
||||||
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
|
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
|
||||||
class="text-sm text-primary hover:underline font-medium">
|
class="text-sm text-primary hover:underline font-medium">
|
||||||
{{ t('notification.actions.markAllRead') }}
|
Mark all read
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification List -->
|
||||||
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
||||||
<template v-if="notificationStore.loading.value">
|
<template v-if="notifications.length > 0">
|
||||||
<div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
|
<div v-for="notification in notifications" :key="notification.id"
|
||||||
<div class="flex items-start gap-4">
|
|
||||||
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
|
|
||||||
<div class="flex-1 space-y-2">
|
|
||||||
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
|
|
||||||
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
<template v-else-if="mutableNotifications.length > 0">
|
|
||||||
<div v-for="notification in mutableNotifications" :key="notification.id"
|
|
||||||
class="border-b border-gray-50 last:border-0">
|
class="border-b border-gray-50 last:border-0">
|
||||||
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
||||||
@delete="handleDelete" isDrawer />
|
@delete="handleDelete" isDrawer />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
<div v-else class="py-12 text-center">
|
<div v-else class="py-12 text-center">
|
||||||
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
|
<span class="i-lucide-bell-off w-12 h-12 text-gray-300 mx-auto block mb-3"></span>
|
||||||
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
|
<p class="text-gray-500 text-sm">No notifications</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
<!-- Footer -->
|
||||||
|
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
||||||
<router-link to="/notification"
|
<router-link to="/notification"
|
||||||
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
||||||
@click="visible = false">
|
@click="visible = false">
|
||||||
{{ t('notification.actions.viewAll') }}
|
View all notifications
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<!-- <style>
|
||||||
|
.notification-popover {
|
||||||
|
border-radius: 16px !important;
|
||||||
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.08) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notification-popover .p-popover-content {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
</style> -->
|
||||||
|
|||||||
@@ -1,10 +1,3 @@
|
|||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
|
||||||
<AppTopLoadingBar />
|
|
||||||
</ClientOnly>
|
|
||||||
<router-view/>
|
<router-view/>
|
||||||
</template>
|
</template>
|
||||||
<script setup lang="ts">
|
|
||||||
import ClientOnly from '@/components/ClientOnly';
|
|
||||||
import AppTopLoadingBar from '@/components/AppTopLoadingBar.vue'
|
|
||||||
</script>
|
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { computed } from 'vue';
|
|
||||||
|
|
||||||
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
|
|
||||||
type Size = 'sm' | 'md';
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
variant?: Variant;
|
|
||||||
size?: Size;
|
|
||||||
loading?: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
type?: 'button' | 'submit' | 'reset';
|
|
||||||
}>(), {
|
|
||||||
variant: 'primary',
|
|
||||||
size: 'md',
|
|
||||||
loading: false,
|
|
||||||
disabled: false,
|
|
||||||
type: 'button',
|
|
||||||
});
|
|
||||||
|
|
||||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all press-animated select-none';
|
|
||||||
|
|
||||||
const sizeClass = computed(() => {
|
|
||||||
switch (props.size) {
|
|
||||||
case 'sm':
|
|
||||||
return 'px-3 py-1.5 text-sm';
|
|
||||||
case 'md':
|
|
||||||
default:
|
|
||||||
return 'px-4 py-2 text-sm';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const variantClass = computed(() => {
|
|
||||||
switch (props.variant) {
|
|
||||||
case 'secondary':
|
|
||||||
return 'bg-muted/50 text-foreground hover:bg-muted border border-border';
|
|
||||||
case 'danger':
|
|
||||||
return 'bg-danger text-white hover:bg-danger/90';
|
|
||||||
case 'ghost':
|
|
||||||
return 'bg-transparent text-foreground/70 hover:text-foreground hover:bg-muted/50';
|
|
||||||
case 'primary':
|
|
||||||
default:
|
|
||||||
return 'bg-primary text-white hover:bg-primary/90';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const disabledClass = computed(() => (props.disabled || props.loading) ? 'opacity-60 cursor-not-allowed' : '');
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
:type="type"
|
|
||||||
:disabled="disabled || loading"
|
|
||||||
:class="cn(baseClass, sizeClass, variantClass, disabledClass)"
|
|
||||||
>
|
|
||||||
<span v-if="loading" class="inline-flex items-center" aria-hidden="true">
|
|
||||||
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
|
||||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
|
||||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
|
|
||||||
</svg>
|
|
||||||
</span>
|
|
||||||
<slot name="icon" />
|
|
||||||
<slot />
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import XIcon from '@/components/icons/XIcon.vue';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
// 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 { t } = useTranslation();
|
|
||||||
|
|
||||||
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="t('common.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>
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
|
||||||
modelValue: boolean;
|
|
||||||
disabled?: boolean;
|
|
||||||
ariaLabel?: string;
|
|
||||||
}>(), {
|
|
||||||
disabled: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
(e: 'update:modelValue', value: boolean): void;
|
|
||||||
(e: 'change', value: boolean): void;
|
|
||||||
}>();
|
|
||||||
|
|
||||||
const toggle = () => {
|
|
||||||
if (props.disabled) return;
|
|
||||||
const next = !props.modelValue;
|
|
||||||
emit('update:modelValue', next);
|
|
||||||
emit('change', next);
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
role="switch"
|
|
||||||
:aria-checked="modelValue"
|
|
||||||
:aria-label="ariaLabel"
|
|
||||||
:disabled="disabled"
|
|
||||||
@click="toggle"
|
|
||||||
:class="cn(
|
|
||||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
|
||||||
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
|
|
||||||
modelValue ? 'bg-primary' : 'bg-border'
|
|
||||||
)"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
:class="cn(
|
|
||||||
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
|
|
||||||
modelValue ? 'translate-x-5' : 'translate-x-1'
|
|
||||||
)"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</template>
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<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="$t('toast.dismissAria')"
|
|
||||||
>
|
|
||||||
<XIcon class="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</TransitionGroup>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import { VNode } from 'vue';
|
import { VNode } from 'vue';
|
||||||
|
|
||||||
interface Trend {
|
interface Trend {
|
||||||
@@ -19,8 +18,6 @@ withDefaults(defineProps<Props>(), {
|
|||||||
color: 'primary'
|
color: 'primary'
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// const gradients = {
|
// const gradients = {
|
||||||
// primary: 'from-primary/20 to-primary/5',
|
// primary: 'from-primary/20 to-primary/5',
|
||||||
// success: 'from-success/20 to-success/5',
|
// success: 'from-success/20 to-success/5',
|
||||||
@@ -79,7 +76,7 @@ const iconColors = {
|
|||||||
</svg>
|
</svg>
|
||||||
{{ Math.abs(trend.value) }}%
|
{{ Math.abs(trend.value) }}%
|
||||||
</span>
|
</span>
|
||||||
<span class="text-gray-500">{{ t('overview.stats.trendVsLastMonth') }}</span>
|
<span class="text-gray-500">vs last month</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
22
src/components/form/Field.vue
Normal file
22
src/components/form/Field.vue
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useField } from '@tanstack/vue-form';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
name: string
|
||||||
|
form?: any
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const field = useField({
|
||||||
|
name: props.name,
|
||||||
|
form: props.form
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="props.class">
|
||||||
|
<slot :field="field" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
55
src/components/form/Form.vue
Normal file
55
src/components/form/Form.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useForm } from '@tanstack/vue-form'
|
||||||
|
import { type ZodType } from 'zod'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialValues?: Record<string, any>
|
||||||
|
onSubmit?: (values: any) => void | Promise<void>
|
||||||
|
resolver?: ZodType<any>
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [values: any]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
defaultValues: props.initialValues || {},
|
||||||
|
onSubmit: async ({ value }) => {
|
||||||
|
if (props.onSubmit) {
|
||||||
|
await props.onSubmit(value as Record<string, any>)
|
||||||
|
}
|
||||||
|
emit('submit', value)
|
||||||
|
},
|
||||||
|
validators: props.resolver
|
||||||
|
? {
|
||||||
|
onChange: ({ value }) => {
|
||||||
|
const result = props.resolver!.safeParse(value)
|
||||||
|
if (result.success) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return result.error.issues.map(issue => ({
|
||||||
|
path: issue.path.join('.'),
|
||||||
|
message: issue.message
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
form.handleSubmit()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form
|
||||||
|
:class="props.class"
|
||||||
|
@submit="handleSubmit"
|
||||||
|
>
|
||||||
|
<slot :form="form" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
36
src/components/form/Message.vue
Normal file
36
src/components/form/Message.vue
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
severity?: 'error' | 'success' | 'info' | 'warn'
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
severity: 'error',
|
||||||
|
size: 'sm'
|
||||||
|
})
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
error: 'text-red-600',
|
||||||
|
success: 'text-green-600',
|
||||||
|
info: 'text-blue-600',
|
||||||
|
warn: 'text-yellow-600'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'text-xs',
|
||||||
|
md: 'text-sm'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
severityClasses[severity],
|
||||||
|
sizeClasses[size],
|
||||||
|
props.class
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||||
<path
|
<path
|
||||||
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
||||||
fill="currentColor" />
|
fill="#1e3050" />
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<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,11 +1,3 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
defineProps<{
|
|
||||||
filled?: boolean;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
<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">
|
<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>
|
||||||
<polyline points="20 6 9 17 4 12" />
|
|
||||||
</svg>
|
|
||||||
</template>
|
</template>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<script lang="ts" setup>
|
|
||||||
defineProps<{
|
|
||||||
filled?: boolean;
|
|
||||||
}>();
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 532"><path d="M26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zM138 42c0-9 7-16 16-16h192c9 0 16 7 16 16 0 3 0 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8zm56 267c0 21 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8s-8 4-8 8v16h-5c-24 0-43 19-43 43z" fill="#a6acb9"/><path d="M346 26c9 0 16 7 16 16 0 3-1 5-2 8l-46 88H187l-47-88c-1-3-2-5-2-8 0-9 7-16 16-16h192zM126 57l45 86C85 222 10 299 10 427c0 52 43 95 95 95h290c52 0 95-43 95-95 0-128-75-205-161-284l45-86c3-5 4-10 4-15 0-18-14-32-32-32H154c-18 0-32 14-32 32 0 5 1 10 4 15zM26 427c0-121 70-194 157-273h134c87 79 157 152 157 273 0 44-35 79-79 79H105c-44 0-79-35-79-79zm224-185c-4 0-8 4-8 8v16h-5c-24 0-43 19-43 43 0 20 15 38 36 42l38 6c13 2 22 13 22 26 0 15-12 27-27 27h-53c-4 0-8 4-8 8s4 8 8 8h32v16c0 4 4 8 8 8s8-4 8-8v-16h5c24 0 43-19 43-43 0-20-15-38-36-42l-38-6c-12-2-22-13-22-26 0-15 12-27 27-27h45c4 0 8-3 8-8 0-4-4-8-8-8h-24v-16c0-4-4-8-8-8z" fill="currentColor"/></svg>
|
|
||||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="532" viewBox="6 -258 500 532"><path d="m379-191-46 81c84 77 163 154 163 279 0 52-43 95-95 95H111c-52 0-95-43-95-95C16 44 96-33 179-110l-46-81c-3-6-5-12-5-19 0-21 17-38 38-38h180c21 0 38 17 38 38 0 7-2 13-5 19zM227-88l-1 1C134-4 64 61 64 169c0 26 21 47 47 47h290c26 0 47-21 47-47C448 61 378-4 286-87l-1-1h-58zm-7-48h72l37-64H183l37 64zm40 96c11 0 20 9 20 20v4h8c11 0 20 9 20 20s-9 20-20 20h-47c-7 0-13 6-13 13 0 6 4 11 10 12l42 7c25 4 44 26 44 52s-19 47-44 51v5c0 11-9 20-20 20s-20-9-20-20v-4h-24c-11 0-20-9-20-20s9-20 20-20h56c6 0 12-5 12-12 0-6-4-12-10-13l-42-7c-25-4-44-26-44-51 0-29 23-53 52-53v-4c0-11 9-20 20-20z" fill="currentColor"/></svg>
|
|
||||||
</template>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
|
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
|
||||||
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
|
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
|
||||||
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
fill="#a6acb9" />
|
||||||
<path
|
<path
|
||||||
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
||||||
fill="var(--colors-primary-DEFAULT)" />
|
fill="#1e3050" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
|
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#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>
|
<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>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
defineProps<{ filled?: boolean }>();
|
defineProps<{ filled?: boolean }>();
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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,5 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="var(--colors-primary-DEFAULT)"/></svg>
|
<svg v-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-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
|
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
|
||||||
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
|
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
|
||||||
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
|
fill="#a6acb9" />
|
||||||
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
|
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
|
||||||
fill="var(--colors-primary-DEFAULT)" />
|
fill="#1e3050" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
||||||
<path
|
<path
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
<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>
|
|
||||||
36
src/components/table/Column.ts
Normal file
36
src/components/table/Column.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { createColumnHelper, type ColumnDef } from '@tanstack/vue-table'
|
||||||
|
|
||||||
|
export { createColumnHelper }
|
||||||
|
export type { ColumnDef }
|
||||||
|
|
||||||
|
// Helper function to create a simple column
|
||||||
|
export function createColumn<T>(
|
||||||
|
accessorKey: keyof T,
|
||||||
|
header: string,
|
||||||
|
options?: {
|
||||||
|
cell?: (value: any, row: T) => any
|
||||||
|
enableSorting?: boolean
|
||||||
|
size?: number
|
||||||
|
}
|
||||||
|
): ColumnDef<T, any> {
|
||||||
|
return {
|
||||||
|
accessorKey: accessorKey as string,
|
||||||
|
header,
|
||||||
|
enableSorting: options?.enableSorting ?? true,
|
||||||
|
size: options?.size,
|
||||||
|
cell: options?.cell
|
||||||
|
? ({ getValue, row }) => options.cell!(getValue(), row.original)
|
||||||
|
: ({ getValue }) => getValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for selection column
|
||||||
|
export function createSelectionColumn<T>(): ColumnDef<T, any> {
|
||||||
|
return {
|
||||||
|
id: 'select',
|
||||||
|
header: ({ table }) => null,
|
||||||
|
cell: () => null,
|
||||||
|
size: 50,
|
||||||
|
enableSorting: false
|
||||||
|
}
|
||||||
|
}
|
||||||
116
src/components/table/DataTable.vue
Normal file
116
src/components/table/DataTable.vue
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
FlexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useVueTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type SortingState
|
||||||
|
} from '@tanstack/vue-table'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props<T> {
|
||||||
|
data: T[]
|
||||||
|
columns: ColumnDef<T, any>[]
|
||||||
|
sorting?: SortingState
|
||||||
|
enableSorting?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props<any>>(), {
|
||||||
|
sorting: () => [],
|
||||||
|
enableSorting: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:sorting': [value: SortingState]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortingState = ref<SortingState>(props.sorting)
|
||||||
|
|
||||||
|
const table = useVueTable({
|
||||||
|
get data() {
|
||||||
|
return props.data
|
||||||
|
},
|
||||||
|
get columns() {
|
||||||
|
return props.columns
|
||||||
|
},
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: props.enableSorting ? getSortedRowModel() : undefined,
|
||||||
|
onSortingChange: (updater) => {
|
||||||
|
if (typeof updater === 'function') {
|
||||||
|
sortingState.value = updater(sortingState.value)
|
||||||
|
} else {
|
||||||
|
sortingState.value = updater
|
||||||
|
}
|
||||||
|
emit('update:sorting', sortingState.value)
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
get sorting() {
|
||||||
|
return sortingState.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['overflow-x-auto', props.class]">
|
||||||
|
<table class="w-full text-sm text-left">
|
||||||
|
<thead class="text-xs text-gray-500 uppercase bg-gray-50 border-b border-gray-200">
|
||||||
|
<tr
|
||||||
|
v-for="headerGroup in table.getHeaderGroups()"
|
||||||
|
:key="headerGroup.id"
|
||||||
|
>
|
||||||
|
<th
|
||||||
|
v-for="header in headerGroup.headers"
|
||||||
|
:key="header.id"
|
||||||
|
:colSpan="header.colSpan"
|
||||||
|
:class="[
|
||||||
|
'px-6 py-3 font-medium',
|
||||||
|
header.column.getCanSort() ? 'cursor-pointer select-none hover:bg-gray-100' : ''
|
||||||
|
]"
|
||||||
|
@click="header.column.getToggleSortingHandler()?.($event)"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
v-if="!header.isPlaceholder"
|
||||||
|
:render="header.column.columnDef.header"
|
||||||
|
:props="header.getContext()"
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
v-if="header.column.getIsSorted()"
|
||||||
|
class="ml-1"
|
||||||
|
>
|
||||||
|
{{ header.column.getIsSorted() === 'asc' ? '↑' : '↓' }}
|
||||||
|
</span>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 bg-white">
|
||||||
|
<tr
|
||||||
|
v-for="row in table.getRowModel().rows"
|
||||||
|
:key="row.id"
|
||||||
|
class="hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td
|
||||||
|
v-for="cell in row.getVisibleCells()"
|
||||||
|
:key="cell.id"
|
||||||
|
class="px-6 py-4"
|
||||||
|
>
|
||||||
|
<FlexRender
|
||||||
|
:render="cell.column.columnDef.cell"
|
||||||
|
:props="cell.getContext()"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-if="table.getRowModel().rows.length === 0">
|
||||||
|
<td
|
||||||
|
:colSpan="table.getAllColumns().length"
|
||||||
|
class="px-6 py-8 text-center text-gray-500"
|
||||||
|
>
|
||||||
|
No data available
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
71
src/components/ui/Avatar.vue
Normal file
71
src/components/ui/Avatar.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
image?: string
|
||||||
|
label?: string
|
||||||
|
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||||
|
shape?: 'circle' | 'square'
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
size: 'md',
|
||||||
|
shape: 'circle'
|
||||||
|
})
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
xs: 'w-6 h-6 text-xs',
|
||||||
|
sm: 'w-8 h-8 text-sm',
|
||||||
|
md: 'w-10 h-10 text-base',
|
||||||
|
lg: 'w-12 h-12 text-lg',
|
||||||
|
xl: 'w-16 h-16 text-xl'
|
||||||
|
}
|
||||||
|
|
||||||
|
const initials = computed(() => {
|
||||||
|
if (!props.label) return ''
|
||||||
|
return props.label
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.slice(0, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
const bgColor = computed(() => {
|
||||||
|
const colors = [
|
||||||
|
'bg-red-500', 'bg-orange-500', 'bg-amber-500', 'bg-yellow-500',
|
||||||
|
'bg-lime-500', 'bg-green-500', 'bg-emerald-500', 'bg-teal-500',
|
||||||
|
'bg-cyan-500', 'bg-sky-500', 'bg-blue-500', 'bg-indigo-500',
|
||||||
|
'bg-violet-500', 'bg-purple-500', 'bg-fuchsia-500', 'bg-pink-500',
|
||||||
|
'bg-rose-500'
|
||||||
|
]
|
||||||
|
if (!props.label) return 'bg-gray-400'
|
||||||
|
let hash = 0
|
||||||
|
for (let i = 0; i < props.label.length; i++) {
|
||||||
|
hash = props.label.charCodeAt(i) + ((hash << 5) - hash)
|
||||||
|
}
|
||||||
|
return colors[Math.abs(hash) % colors.length]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center justify-center overflow-hidden font-medium text-white',
|
||||||
|
sizeClasses[size],
|
||||||
|
shape === 'circle' ? 'rounded-full' : 'rounded-lg',
|
||||||
|
!image ? bgColor : '',
|
||||||
|
props.class
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="image"
|
||||||
|
:src="image"
|
||||||
|
:alt="label || 'Avatar'"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
<span v-else-if="initials">{{ initials }}</span>
|
||||||
|
<span v-else class="i-heroicons-user w-1/2 h-1/2" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
59
src/components/ui/Button.vue
Normal file
59
src/components/ui/Button.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'button' | 'submit' | 'reset'
|
||||||
|
variant?: 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger'
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
disabled?: boolean
|
||||||
|
loading?: boolean
|
||||||
|
fluid?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'button',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'md',
|
||||||
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
fluid: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: [event: MouseEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
|
||||||
|
secondary: 'bg-gray-600 text-white hover:bg-gray-700 focus:ring-gray-500',
|
||||||
|
outline: 'border-2 border-gray-300 bg-transparent text-gray-700 hover:bg-gray-50 focus:ring-gray-500',
|
||||||
|
ghost: 'bg-transparent text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
:type="type"
|
||||||
|
:disabled="disabled || loading"
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
fluid ? 'w-full' : '',
|
||||||
|
props.class
|
||||||
|
]"
|
||||||
|
@click="emit('click', $event)"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="loading"
|
||||||
|
class="i-heroicons-arrow-path mr-2 animate-spin w-4 h-4"
|
||||||
|
/>
|
||||||
|
<slot />
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
28
src/components/ui/Card.vue
Normal file
28
src/components/ui/Card.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden" :class="props.class">
|
||||||
|
<!-- Header slot -->
|
||||||
|
<div v-if="$slots.header" class="border-b border-gray-100">
|
||||||
|
<slot name="header" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div>
|
||||||
|
<slot name="content">
|
||||||
|
<slot />
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer slot -->
|
||||||
|
<div v-if="$slots.footer" class="border-t border-gray-100">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
src/components/ui/Checkbox.vue
Normal file
87
src/components/ui/Checkbox.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
modelValue: any[] | boolean | undefined
|
||||||
|
value?: any
|
||||||
|
name?: string
|
||||||
|
disabled?: boolean
|
||||||
|
size?: 'sm' | 'md'
|
||||||
|
binary?: boolean
|
||||||
|
inputId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
size: 'md',
|
||||||
|
binary: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: any[] | boolean]
|
||||||
|
click: [event: MouseEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-5 h-5'
|
||||||
|
}
|
||||||
|
|
||||||
|
const isChecked = (): boolean => {
|
||||||
|
if (props.binary) {
|
||||||
|
return !!(props.modelValue as boolean)
|
||||||
|
}
|
||||||
|
return Array.isArray(props.modelValue) && props.value !== undefined
|
||||||
|
? props.modelValue.includes(props.value)
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggle = (event?: MouseEvent) => {
|
||||||
|
if (props.binary) {
|
||||||
|
emit('update:modelValue', !props.modelValue)
|
||||||
|
} else {
|
||||||
|
const currentValue = Array.isArray(props.modelValue) ? props.modelValue : []
|
||||||
|
if (props.value !== undefined) {
|
||||||
|
if (currentValue.includes(props.value)) {
|
||||||
|
emit('update:modelValue', currentValue.filter(v => v !== props.value))
|
||||||
|
} else {
|
||||||
|
emit('update:modelValue', [...currentValue, props.value])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (event) {
|
||||||
|
emit('click', event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center"
|
||||||
|
:class="disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
|
||||||
|
@click="!disabled && toggle($event)"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
sizeClasses[size],
|
||||||
|
'rounded border-2 flex items-center justify-center transition-colors',
|
||||||
|
isChecked()
|
||||||
|
? 'bg-blue-600 border-blue-600'
|
||||||
|
: 'bg-white border-gray-300 hover:border-gray-400'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isChecked()"
|
||||||
|
class="i-heroicons-check text-white"
|
||||||
|
:class="size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:name="name"
|
||||||
|
:id="inputId"
|
||||||
|
:checked="isChecked()"
|
||||||
|
:disabled="disabled"
|
||||||
|
class="sr-only"
|
||||||
|
@change="toggle()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
124
src/components/ui/Dialog.vue
Normal file
124
src/components/ui/Dialog.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, watch } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
visible: boolean
|
||||||
|
header?: string
|
||||||
|
width?: string
|
||||||
|
closable?: boolean
|
||||||
|
draggable?: boolean
|
||||||
|
modal?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
header: '',
|
||||||
|
width: '28rem',
|
||||||
|
closable: true,
|
||||||
|
draggable: false,
|
||||||
|
modal: true
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
emit('update:visible', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
if (props.closable) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.visible && props.closable) {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.visible, (visible) => {
|
||||||
|
if (visible) {
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0"
|
||||||
|
enter-to-class="opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100"
|
||||||
|
leave-to-class="opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="fixed inset-0 z-50"
|
||||||
|
:class="[modal ? 'bg-black/50' : '']"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
>
|
||||||
|
<div class="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition ease-out duration-200"
|
||||||
|
enter-from-class="opacity-0 scale-95"
|
||||||
|
enter-to-class="opacity-100 scale-100"
|
||||||
|
leave-active-class="transition ease-in duration-150"
|
||||||
|
leave-from-class="opacity-100 scale-100"
|
||||||
|
leave-to-class="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="visible"
|
||||||
|
class="relative bg-white rounded-xl shadow-xl"
|
||||||
|
:style="{ width, maxWidth: 'calc(100vw - 2rem)' }"
|
||||||
|
:class="props.class"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div
|
||||||
|
v-if="header || $slots.header || closable"
|
||||||
|
class="flex items-center justify-between px-6 py-4 border-b border-gray-200"
|
||||||
|
>
|
||||||
|
<slot name="header">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ header }}</h3>
|
||||||
|
</slot>
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
type="button"
|
||||||
|
class="text-gray-400 hover:text-gray-600 transition-colors"
|
||||||
|
@click="handleClose"
|
||||||
|
>
|
||||||
|
<span class="i-heroicons-x-mark w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="px-6 py-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div v-if="$slots.footer" class="px-6 py-4 border-t border-gray-200">
|
||||||
|
<slot name="footer" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
75
src/components/ui/Input.vue
Normal file
75
src/components/ui/Input.vue
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string | number
|
||||||
|
name?: string
|
||||||
|
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url'
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
fluid?: boolean
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
invalid?: boolean
|
||||||
|
class?: string | Record<string, boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'text',
|
||||||
|
disabled: false,
|
||||||
|
fluid: false,
|
||||||
|
size: 'md',
|
||||||
|
invalid: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | number]
|
||||||
|
blur: [event: FocusEvent]
|
||||||
|
focus: [event: FocusEvent]
|
||||||
|
input: [event: Event]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-4 py-3 text-base'
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseClasses = computed(() => [
|
||||||
|
'block w-full rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
|
||||||
|
'disabled:bg-gray-100 disabled:cursor-not-allowed',
|
||||||
|
props.invalid ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : '',
|
||||||
|
sizeClasses[props.size]
|
||||||
|
])
|
||||||
|
|
||||||
|
const inputClasses = computed(() => {
|
||||||
|
if (typeof props.class === 'string') {
|
||||||
|
return twMerge(baseClasses.value.join(' '), props.class)
|
||||||
|
}
|
||||||
|
return twMerge(baseClasses.value.join(' '))
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
const value = props.type === 'number'
|
||||||
|
? (target.valueAsNumber || 0)
|
||||||
|
: target.value
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
emit('input', event)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:name="name"
|
||||||
|
:type="type"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="inputClasses"
|
||||||
|
@input="handleInput"
|
||||||
|
@blur="emit('blur', $event)"
|
||||||
|
@focus="emit('focus', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
105
src/components/ui/InputPassword.vue
Normal file
105
src/components/ui/InputPassword.vue
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
modelValue?: string
|
||||||
|
name?: string
|
||||||
|
placeholder?: string
|
||||||
|
disabled?: boolean
|
||||||
|
fluid?: boolean
|
||||||
|
size?: 'sm' | 'md' | 'lg'
|
||||||
|
invalid?: boolean
|
||||||
|
feedback?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
disabled: false,
|
||||||
|
fluid: false,
|
||||||
|
size: 'md',
|
||||||
|
invalid: false,
|
||||||
|
feedback: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
blur: [event: FocusEvent]
|
||||||
|
focus: [event: FocusEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showPassword = ref(false)
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
sm: 'px-3 py-1.5 text-sm',
|
||||||
|
md: 'px-4 py-2 text-sm',
|
||||||
|
lg: 'px-4 py-3 text-base'
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClasses = computed(() => [
|
||||||
|
'block w-full rounded-lg border border-gray-300 bg-white text-gray-900 placeholder-gray-400',
|
||||||
|
'focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500',
|
||||||
|
'disabled:bg-gray-100 disabled:cursor-not-allowed pr-10',
|
||||||
|
props.invalid ? 'border-red-500 focus:ring-red-500 focus:border-red-500' : '',
|
||||||
|
sizeClasses[props.size]
|
||||||
|
])
|
||||||
|
|
||||||
|
const passwordStrength = computed(() => {
|
||||||
|
if (!props.modelValue) return 0
|
||||||
|
let strength = 0
|
||||||
|
if (props.modelValue.length >= 8) strength++
|
||||||
|
if (/[A-Z]/.test(props.modelValue)) strength++
|
||||||
|
if (/[0-9]/.test(props.modelValue)) strength++
|
||||||
|
if (/[^A-Za-z0-9]/.test(props.modelValue)) strength++
|
||||||
|
return strength
|
||||||
|
})
|
||||||
|
|
||||||
|
const strengthText = computed(() => {
|
||||||
|
const texts = ['Very Weak', 'Weak', 'Fair', 'Good', 'Strong']
|
||||||
|
return texts[passwordStrength.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
const strengthColor = computed(() => {
|
||||||
|
const colors = ['bg-red-500', 'bg-red-400', 'bg-yellow-400', 'bg-blue-400', 'bg-green-500']
|
||||||
|
return colors[passwordStrength.value]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="[fluid ? 'w-full' : '', props.class]">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
:name="name"
|
||||||
|
:type="showPassword ? 'text' : 'password'"
|
||||||
|
:value="modelValue"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="inputClasses"
|
||||||
|
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||||
|
@blur="emit('blur', $event)"
|
||||||
|
@focus="emit('focus', $event)"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute inset-y-0 right-0 flex items-center pr-3 text-gray-400 hover:text-gray-600"
|
||||||
|
@click="showPassword = !showPassword"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'"
|
||||||
|
class="w-5 h-5"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="feedback && modelValue" class="mt-2">
|
||||||
|
<div class="flex gap-1 h-1 mb-1">
|
||||||
|
<div
|
||||||
|
v-for="i in 4"
|
||||||
|
:key="i"
|
||||||
|
class="flex-1 rounded-full transition-colors"
|
||||||
|
:class="i <= passwordStrength ? strengthColor : 'bg-gray-200'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">{{ strengthText }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
53
src/components/ui/ProgressBar.vue
Normal file
53
src/components/ui/ProgressBar.vue
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: number
|
||||||
|
showValue?: boolean
|
||||||
|
unit?: string
|
||||||
|
mode?: 'determinate' | 'indeterminate'
|
||||||
|
color?: 'primary' | 'success' | 'warning' | 'danger'
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
value: 0,
|
||||||
|
showValue: true,
|
||||||
|
unit: '%',
|
||||||
|
mode: 'determinate',
|
||||||
|
color: 'primary'
|
||||||
|
})
|
||||||
|
|
||||||
|
const normalizedValue = computed(() => {
|
||||||
|
return Math.max(0, Math.min(100, props.value))
|
||||||
|
})
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
primary: 'bg-blue-600',
|
||||||
|
success: 'bg-green-500',
|
||||||
|
warning: 'bg-yellow-500',
|
||||||
|
danger: 'bg-red-500'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['w-full', props.class]">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1 h-2 bg-gray-200 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
v-if="mode === 'determinate'"
|
||||||
|
:class="['h-full rounded-full transition-all duration-300 ease-out', colorClasses[color]]"
|
||||||
|
:style="{ width: `${normalizedValue}%` }"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
:class="['h-full rounded-full animate-pulse', colorClasses[color]]"
|
||||||
|
style="width: 50%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span v-if="showValue && mode === 'determinate'" class="text-xs font-medium text-gray-600 min-w-[3rem] text-right">
|
||||||
|
{{ normalizedValue }}{{ unit }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
27
src/components/ui/Skeleton.vue
Normal file
27
src/components/ui/Skeleton.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
width?: string
|
||||||
|
height?: string
|
||||||
|
borderRadius?: string
|
||||||
|
circle?: boolean
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: '1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
circle: false
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="['animate-pulse bg-gray-200', props.class]"
|
||||||
|
:style="{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius: circle ? '50%' : borderRadius
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
34
src/components/ui/Tag.vue
Normal file
34
src/components/ui/Tag.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
value?: string
|
||||||
|
severity?: 'primary' | 'secondary' | 'success' | 'info' | 'warning' | 'danger'
|
||||||
|
icon?: string
|
||||||
|
class?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
severity: 'primary'
|
||||||
|
})
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
primary: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||||
|
secondary: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
|
success: 'bg-green-100 text-green-800 border-green-200',
|
||||||
|
info: 'bg-cyan-100 text-cyan-800 border-cyan-200',
|
||||||
|
warning: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
|
danger: 'bg-red-100 text-red-800 border-red-200'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium border',
|
||||||
|
severityClasses[severity],
|
||||||
|
props.class
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span v-if="icon" :class="[icon, 'w-3 h-3']" />
|
||||||
|
<slot>{{ value }}</slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
73
src/components/ui/Toast.vue
Normal file
73
src/components/ui/Toast.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { useToast, type ToastSeverity } from '@/composables/useToast'
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const severityIcons: Record<ToastSeverity, string> = {
|
||||||
|
success: 'i-heroicons-check-circle',
|
||||||
|
error: 'i-heroicons-x-circle',
|
||||||
|
info: 'i-heroicons-information-circle',
|
||||||
|
warn: 'i-heroicons-exclamation-triangle'
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityClasses: Record<ToastSeverity, string> = {
|
||||||
|
success: 'bg-green-50 border-green-200 text-green-800',
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
warn: 'bg-yellow-50 border-yellow-200 text-yellow-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
const severityIconColors: Record<ToastSeverity, string> = {
|
||||||
|
success: 'text-green-500',
|
||||||
|
error: 'text-red-500',
|
||||||
|
info: 'text-blue-500',
|
||||||
|
warn: 'text-yellow-500'
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleClose = (id: string) => {
|
||||||
|
toast.remove(id)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed top-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
|
||||||
|
<TransitionGroup
|
||||||
|
enter-active-class="transition ease-out duration-300"
|
||||||
|
enter-from-class="translate-x-full opacity-0"
|
||||||
|
enter-to-class="translate-x-0 opacity-100"
|
||||||
|
leave-active-class="transition ease-in duration-200"
|
||||||
|
leave-from-class="translate-x-0 opacity-100"
|
||||||
|
leave-to-class="translate-x-full opacity-0"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="message in toast.messages"
|
||||||
|
:key="message.id"
|
||||||
|
:class="[
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border shadow-lg min-w-[300px]',
|
||||||
|
severityClasses[message.severity]
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
severityIcons[message.severity],
|
||||||
|
severityIconColors[message.severity],
|
||||||
|
'w-5 h-5 flex-shrink-0 mt-0.5'
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium text-sm">{{ message.summary }}</p>
|
||||||
|
<p v-if="message.detail" class="text-sm opacity-90 mt-0.5">{{ message.detail }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex-shrink-0 opacity-60 hover:opacity-100 transition-opacity"
|
||||||
|
@click="handleClose(message.id)"
|
||||||
|
>
|
||||||
|
<span class="i-heroicons-x-mark w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
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) => {
|
|
||||||
const defaultHeader = 'Confirm';
|
|
||||||
const defaultAccept = 'OK';
|
|
||||||
const defaultReject = 'Cancel';
|
|
||||||
|
|
||||||
state.visible = true;
|
|
||||||
state.loading = false;
|
|
||||||
state.message = options.message;
|
|
||||||
state.header = options.header ?? defaultHeader;
|
|
||||||
state.acceptLabel = options.acceptLabel ?? defaultAccept;
|
|
||||||
state.rejectLabel = options.rejectLabel ?? defaultReject;
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
174
src/composables/useDataLoader.ts
Normal file
174
src/composables/useDataLoader.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
interface DataLoaderOptions<T> {
|
||||||
|
key: string
|
||||||
|
fetcher: () => Promise<T>
|
||||||
|
revalidateOnFocus?: boolean
|
||||||
|
revalidateOnReconnect?: boolean
|
||||||
|
refreshInterval?: number
|
||||||
|
dedupingInterval?: number
|
||||||
|
fallbackData?: T
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DataLoaderState<T> {
|
||||||
|
data: T | undefined
|
||||||
|
error: Error | null
|
||||||
|
isLoading: boolean
|
||||||
|
isValidating: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global cache
|
||||||
|
const cache = new Map<string, { data: any; timestamp: number }>()
|
||||||
|
const dedupeTimers = new Map<string, number>()
|
||||||
|
const DEDUPING_INTERVAL = 2000
|
||||||
|
|
||||||
|
export function useDataLoader<T>(options: DataLoaderOptions<T>) {
|
||||||
|
const route = useRoute()
|
||||||
|
const {
|
||||||
|
key,
|
||||||
|
fetcher,
|
||||||
|
revalidateOnFocus = false,
|
||||||
|
revalidateOnReconnect = true,
|
||||||
|
refreshInterval,
|
||||||
|
fallbackData
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const data = ref<T | undefined>(fallbackData)
|
||||||
|
const error = ref<Error | null>(null)
|
||||||
|
const isLoading = ref(false)
|
||||||
|
const isValidating = ref(false)
|
||||||
|
|
||||||
|
let refreshTimer: number | null = null
|
||||||
|
let isMounted = false
|
||||||
|
|
||||||
|
const mutate = async (newData?: T): Promise<T | undefined> => {
|
||||||
|
if (newData !== undefined) {
|
||||||
|
data.value = newData
|
||||||
|
cache.set(key, { data: newData, timestamp: Date.now() })
|
||||||
|
return newData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe requests
|
||||||
|
if (dedupeTimers.has(key)) {
|
||||||
|
return data.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const dedupeKey = key
|
||||||
|
dedupeTimers.set(dedupeKey, window.setTimeout(() => {
|
||||||
|
dedupeTimers.delete(dedupeKey)
|
||||||
|
}, DEDUPING_INTERVAL))
|
||||||
|
|
||||||
|
isValidating.value = true
|
||||||
|
if (!data.value) {
|
||||||
|
isLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await fetcher()
|
||||||
|
data.value = result
|
||||||
|
error.value = null
|
||||||
|
cache.set(key, { data: result, timestamp: Date.now() })
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
error.value = err as Error
|
||||||
|
throw err
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false
|
||||||
|
isValidating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
const load = async () => {
|
||||||
|
const cached = cache.get(key)
|
||||||
|
if (cached && Date.now() - cached.timestamp < DEDUPING_INTERVAL) {
|
||||||
|
data.value = cached.data
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate on focus
|
||||||
|
const handleFocus = () => {
|
||||||
|
if (revalidateOnFocus && document.visibilityState === 'visible') {
|
||||||
|
mutate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Revalidate on reconnect
|
||||||
|
const handleOnline = () => {
|
||||||
|
if (revalidateOnReconnect) {
|
||||||
|
mutate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup refresh interval
|
||||||
|
const setupRefreshInterval = () => {
|
||||||
|
if (refreshInterval && refreshInterval > 0) {
|
||||||
|
refreshTimer = window.setInterval(() => {
|
||||||
|
mutate()
|
||||||
|
}, refreshInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup refresh interval
|
||||||
|
const cleanupRefreshInterval = () => {
|
||||||
|
if (refreshTimer) {
|
||||||
|
clearInterval(refreshTimer)
|
||||||
|
refreshTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
isMounted = true
|
||||||
|
load()
|
||||||
|
|
||||||
|
if (revalidateOnFocus) {
|
||||||
|
document.addEventListener('visibilitychange', handleFocus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revalidateOnReconnect) {
|
||||||
|
window.addEventListener('online', handleOnline)
|
||||||
|
}
|
||||||
|
|
||||||
|
setupRefreshInterval()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
isMounted = false
|
||||||
|
cleanupRefreshInterval()
|
||||||
|
|
||||||
|
if (revalidateOnFocus) {
|
||||||
|
document.removeEventListener('visibilitychange', handleFocus)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (revalidateOnReconnect) {
|
||||||
|
window.removeEventListener('online', handleOnline)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Revalidate when key changes
|
||||||
|
watch(() => key, () => {
|
||||||
|
if (isMounted) {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: computed(() => data.value),
|
||||||
|
error: computed(() => error.value),
|
||||||
|
isLoading: computed(() => isLoading.value),
|
||||||
|
isValidating: computed(() => isValidating.value),
|
||||||
|
mutate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for SSR compatibility
|
||||||
|
export const useSWRV = <T>(key: string, fetcher: () => Promise<T>) => {
|
||||||
|
return useDataLoader<T>({
|
||||||
|
key,
|
||||||
|
fetcher,
|
||||||
|
revalidateOnFocus: false
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { client } from '@/api/client';
|
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
|
|
||||||
export type NotificationType = 'info' | 'success' | 'warning' | 'error' | 'video' | 'payment' | 'system';
|
|
||||||
|
|
||||||
export type AppNotification = {
|
|
||||||
id: string;
|
|
||||||
type: NotificationType;
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
time: string;
|
|
||||||
read: boolean;
|
|
||||||
actionUrl?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
createdAt?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NotificationApiItem = {
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
title?: string;
|
|
||||||
message?: string;
|
|
||||||
read?: boolean;
|
|
||||||
actionUrl?: string;
|
|
||||||
actionLabel?: string;
|
|
||||||
action_url?: string;
|
|
||||||
action_label?: string;
|
|
||||||
created_at?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const notifications = ref<AppNotification[]>([]);
|
|
||||||
const loading = ref(false);
|
|
||||||
const loaded = ref(false);
|
|
||||||
|
|
||||||
const normalizeType = (value?: string): NotificationType => {
|
|
||||||
switch ((value || '').toLowerCase()) {
|
|
||||||
case 'video':
|
|
||||||
case 'payment':
|
|
||||||
case 'warning':
|
|
||||||
case 'error':
|
|
||||||
case 'success':
|
|
||||||
case 'system':
|
|
||||||
return value as NotificationType;
|
|
||||||
default:
|
|
||||||
return 'info';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useNotifications() {
|
|
||||||
const { t, i18next } = useTranslation();
|
|
||||||
|
|
||||||
const formatRelativeTime = (value?: string) => {
|
|
||||||
if (!value) return '';
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) return '';
|
|
||||||
|
|
||||||
const diffMs = Date.now() - date.getTime();
|
|
||||||
const minutes = Math.max(1, Math.floor(diffMs / 60000));
|
|
||||||
if (minutes < 60) return t('notification.time.minutesAgo', { count: minutes });
|
|
||||||
const hours = Math.floor(minutes / 60);
|
|
||||||
if (hours < 24) return t('notification.time.hoursAgo', { count: hours });
|
|
||||||
const days = Math.floor(hours / 24);
|
|
||||||
return t('notification.time.daysAgo', { count: Math.max(1, days) });
|
|
||||||
};
|
|
||||||
|
|
||||||
const mapNotification = (item: NotificationApiItem): AppNotification => ({
|
|
||||||
id: item.id || '',
|
|
||||||
type: normalizeType(item.type),
|
|
||||||
title: item.title || '',
|
|
||||||
message: item.message || '',
|
|
||||||
time: formatRelativeTime(item.created_at),
|
|
||||||
read: Boolean(item.read),
|
|
||||||
actionUrl: item.actionUrl || item.action_url || undefined,
|
|
||||||
actionLabel: item.actionLabel || item.action_label || undefined,
|
|
||||||
createdAt: item.created_at,
|
|
||||||
});
|
|
||||||
|
|
||||||
const fetchNotifications = async () => {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const response = await client.notifications.notificationsList({ baseUrl: '/r' });
|
|
||||||
notifications.value = (((response.data as any)?.data?.notifications || []) as NotificationApiItem[]).map(mapNotification);
|
|
||||||
loaded.value = true;
|
|
||||||
return notifications.value;
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const markRead = async (id: string) => {
|
|
||||||
if (!id) return;
|
|
||||||
await client.notifications.readCreate(id, { baseUrl: '/r' });
|
|
||||||
const item = notifications.value.find(notification => notification.id === id);
|
|
||||||
if (item) item.read = true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const deleteNotification = async (id: string) => {
|
|
||||||
if (!id) return;
|
|
||||||
await client.notifications.notificationsDelete2(id, { baseUrl: '/r' });
|
|
||||||
notifications.value = notifications.value.filter(notification => notification.id !== id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const markAllRead = async () => {
|
|
||||||
await client.notifications.readAllCreate({ baseUrl: '/r' });
|
|
||||||
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearAll = async () => {
|
|
||||||
await client.notifications.notificationsDelete({ baseUrl: '/r' });
|
|
||||||
notifications.value = [];
|
|
||||||
};
|
|
||||||
|
|
||||||
const unreadCount = computed(() => notifications.value.filter(item => !item.read).length);
|
|
||||||
|
|
||||||
return {
|
|
||||||
notifications,
|
|
||||||
loading,
|
|
||||||
loaded,
|
|
||||||
unreadCount,
|
|
||||||
locale: computed(() => i18next.resolvedLanguage),
|
|
||||||
fetchNotifications,
|
|
||||||
markRead,
|
|
||||||
deleteNotification,
|
|
||||||
markAllRead,
|
|
||||||
clearAll,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
|
|
||||||
const visible = ref(false)
|
|
||||||
const progress = ref(0)
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
|
|
||||||
visible.value = true
|
|
||||||
progress.value = 8
|
|
||||||
|
|
||||||
timer = setInterval(() => {
|
|
||||||
if (progress.value < 80) {
|
|
||||||
progress.value += Math.random() * 12
|
|
||||||
} else if (progress.value < 95) {
|
|
||||||
progress.value += Math.random() * 3
|
|
||||||
}
|
|
||||||
}, 200)
|
|
||||||
}
|
|
||||||
|
|
||||||
function finish() {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = 100
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
visible.value = false
|
|
||||||
progress.value = 0
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
function fail() {
|
|
||||||
if (timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
|
|
||||||
progress.value = 100
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
visible.value = false
|
|
||||||
progress.value = 0
|
|
||||||
}, 250)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRouteLoading() {
|
|
||||||
return {
|
|
||||||
visible,
|
|
||||||
progress,
|
|
||||||
start,
|
|
||||||
finish,
|
|
||||||
fail,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { client, type PreferencesSettingsPreferencesRequest } from '@/api/client';
|
|
||||||
import { useQuery } from '@pinia/colada';
|
|
||||||
|
|
||||||
export const SETTINGS_PREFERENCES_QUERY_KEY = ['settings', 'preferences'] as const;
|
|
||||||
|
|
||||||
export type SettingsPreferencesSnapshot = {
|
|
||||||
emailNotifications: boolean;
|
|
||||||
pushNotifications: boolean;
|
|
||||||
marketingNotifications: boolean;
|
|
||||||
telegramNotifications: boolean;
|
|
||||||
autoplay: boolean;
|
|
||||||
loop: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
pip: boolean;
|
|
||||||
airplay: boolean;
|
|
||||||
chromecast: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type NotificationSettingsDraft = {
|
|
||||||
email: boolean;
|
|
||||||
push: boolean;
|
|
||||||
marketing: boolean;
|
|
||||||
telegram: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PlayerSettingsDraft = {
|
|
||||||
autoplay: boolean;
|
|
||||||
loop: boolean;
|
|
||||||
muted: boolean;
|
|
||||||
showControls: boolean;
|
|
||||||
pip: boolean;
|
|
||||||
airplay: boolean;
|
|
||||||
chromecast: boolean;
|
|
||||||
encrytion_m3u8: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PreferencesResponse = {
|
|
||||||
data?: {
|
|
||||||
preferences?: PreferencesSettingsPreferencesRequest;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
|
|
||||||
emailNotifications: true,
|
|
||||||
pushNotifications: true,
|
|
||||||
marketingNotifications: false,
|
|
||||||
telegramNotifications: false,
|
|
||||||
autoplay: false,
|
|
||||||
loop: false,
|
|
||||||
muted: false,
|
|
||||||
showControls: true,
|
|
||||||
pip: true,
|
|
||||||
airplay: true,
|
|
||||||
chromecast: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
|
|
||||||
const preferences = (responseData as PreferencesResponse | undefined)?.data?.preferences;
|
|
||||||
|
|
||||||
return {
|
|
||||||
emailNotifications: preferences?.email_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
|
|
||||||
pushNotifications: preferences?.push_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
|
|
||||||
marketingNotifications: preferences?.marketing_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
|
|
||||||
telegramNotifications: preferences?.telegram_notifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
|
|
||||||
autoplay: preferences?.autoplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.autoplay,
|
|
||||||
loop: preferences?.loop ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.loop,
|
|
||||||
muted: preferences?.muted ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.muted,
|
|
||||||
showControls: preferences?.show_controls ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.showControls,
|
|
||||||
pip: preferences?.pip ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pip,
|
|
||||||
airplay: preferences?.airplay ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.airplay,
|
|
||||||
chromecast: preferences?.chromecast ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.chromecast,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const createNotificationSettingsDraft = (
|
|
||||||
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
|
|
||||||
): NotificationSettingsDraft => ({
|
|
||||||
email: snapshot.emailNotifications,
|
|
||||||
push: snapshot.pushNotifications,
|
|
||||||
marketing: snapshot.marketingNotifications,
|
|
||||||
telegram: snapshot.telegramNotifications,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const createPlayerSettingsDraft = (
|
|
||||||
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
|
|
||||||
): PlayerSettingsDraft => ({
|
|
||||||
autoplay: snapshot.autoplay,
|
|
||||||
loop: snapshot.loop,
|
|
||||||
muted: snapshot.muted,
|
|
||||||
showControls: snapshot.showControls,
|
|
||||||
pip: snapshot.pip,
|
|
||||||
airplay: snapshot.airplay,
|
|
||||||
chromecast: snapshot.chromecast,
|
|
||||||
encrytion_m3u8: snapshot.chromecast
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toNotificationPreferencesPayload = (
|
|
||||||
draft: NotificationSettingsDraft,
|
|
||||||
): PreferencesSettingsPreferencesRequest => ({
|
|
||||||
email_notifications: draft.email,
|
|
||||||
push_notifications: draft.push,
|
|
||||||
marketing_notifications: draft.marketing,
|
|
||||||
telegram_notifications: draft.telegram,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const toPlayerPreferencesPayload = (
|
|
||||||
draft: PlayerSettingsDraft,
|
|
||||||
): PreferencesSettingsPreferencesRequest => ({
|
|
||||||
autoplay: draft.autoplay,
|
|
||||||
loop: draft.loop,
|
|
||||||
muted: draft.muted,
|
|
||||||
show_controls: draft.showControls,
|
|
||||||
pip: draft.pip,
|
|
||||||
airplay: draft.airplay,
|
|
||||||
chromecast: draft.chromecast,
|
|
||||||
});
|
|
||||||
|
|
||||||
export function useSettingsPreferencesQuery() {
|
|
||||||
return useQuery({
|
|
||||||
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
|
|
||||||
query: async () => {
|
|
||||||
const response = await client.settings.preferencesList({ baseUrl: '/r' });
|
|
||||||
return normalizePreferencesSnapshot(response.data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
90
src/composables/useToast.ts
Normal file
90
src/composables/useToast.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export type ToastSeverity = 'success' | 'error' | 'info' | 'warn'
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: string
|
||||||
|
severity: ToastSeverity
|
||||||
|
summary: string
|
||||||
|
detail?: string
|
||||||
|
life?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToastState {
|
||||||
|
messages: ToastMessage[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = reactive<ToastState>({
|
||||||
|
messages: []
|
||||||
|
})
|
||||||
|
|
||||||
|
let toastIdCounter = 0
|
||||||
|
|
||||||
|
export const useToast = () => {
|
||||||
|
const add = (message: Omit<ToastMessage, 'id'>) => {
|
||||||
|
const id = `toast-${++toastIdCounter}`
|
||||||
|
const newMessage: ToastMessage = {
|
||||||
|
id,
|
||||||
|
life: 3000,
|
||||||
|
...message
|
||||||
|
}
|
||||||
|
|
||||||
|
state.messages.push(newMessage)
|
||||||
|
|
||||||
|
if (newMessage.life && newMessage.life > 0) {
|
||||||
|
setTimeout(() => {
|
||||||
|
remove(id)
|
||||||
|
}, newMessage.life)
|
||||||
|
}
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = (id: string) => {
|
||||||
|
const index = state.messages.findIndex(m => m.id === id)
|
||||||
|
if (index > -1) {
|
||||||
|
state.messages.splice(index, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clear = () => {
|
||||||
|
state.messages.length = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = (detail: string, summary: string = 'Success') => {
|
||||||
|
return add({ severity: 'success', summary, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = (detail: string, summary: string = 'Error') => {
|
||||||
|
return add({ severity: 'error', summary, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = (detail: string, summary: string = 'Info') => {
|
||||||
|
return add({ severity: 'info', summary, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
const warn = (detail: string, summary: string = 'Warning') => {
|
||||||
|
return add({ severity: 'warn', summary, detail })
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: state.messages,
|
||||||
|
add,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
success,
|
||||||
|
error,
|
||||||
|
info,
|
||||||
|
warn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global toast instance for use outside of components
|
||||||
|
let globalToastInstance: ReturnType<typeof useToast> | null = null
|
||||||
|
|
||||||
|
export const getGlobalToast = () => {
|
||||||
|
if (!globalToastInstance) {
|
||||||
|
globalToastInstance = useToast()
|
||||||
|
}
|
||||||
|
return globalToastInstance
|
||||||
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { client, ContentType } from '@/api/client';
|
import { ref, computed } from 'vue';
|
||||||
import { computed, ref } from 'vue';
|
|
||||||
|
|
||||||
export interface QueueItem {
|
export interface QueueItem {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,113 +12,52 @@ export interface QueueItem {
|
|||||||
thumbnail?: string;
|
thumbnail?: string;
|
||||||
file?: File; // Keep reference to file for local uploads
|
file?: File; // Keep reference to file for local uploads
|
||||||
url?: string; // Keep reference to url for remote uploads
|
url?: string; // Keep reference to url for remote uploads
|
||||||
playbackUrl?: string;
|
|
||||||
videoId?: string;
|
|
||||||
mergeId?: string;
|
|
||||||
// Upload chunk tracking
|
|
||||||
activeChunks?: number;
|
|
||||||
uploadedUrls?: string[];
|
|
||||||
cancelled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const items = ref<QueueItem[]>([]);
|
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() {
|
export function useUploadQueue() {
|
||||||
const t = (key: string, params?: Record<string, unknown>) => key;
|
|
||||||
|
|
||||||
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
|
|
||||||
|
|
||||||
const addFiles = (files: FileList) => {
|
const addFiles = (files: FileList) => {
|
||||||
const allowed = Array.from(files).slice(0, remainingSlots.value);
|
const newItems: QueueItem[] = Array.from(files).map((file) => ({
|
||||||
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),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
name: file.name,
|
name: file.name,
|
||||||
type: 'local',
|
type: 'local',
|
||||||
status: 'pending',
|
status: 'pending', // Start as pending
|
||||||
progress: 0,
|
progress: 0,
|
||||||
uploaded: '0 MB',
|
uploaded: '0 MB',
|
||||||
total: formatSize(file.size),
|
total: formatSize(file.size),
|
||||||
speed: '0 MB/s',
|
speed: '0 MB/s',
|
||||||
file: file,
|
file: file,
|
||||||
thumbnail: undefined,
|
thumbnail: undefined // We could generate a thumbnail here if needed
|
||||||
activeChunks: 0,
|
|
||||||
uploadedUrls: [],
|
|
||||||
cancelled: false
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
items.value.push(...newItems);
|
items.value.push(...newItems);
|
||||||
return { added: newItems.length, skipped: files.length - allowed.length, duplicates: duplicates.length };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addRemoteUrls = (urls: string[]) => {
|
const addRemoteUrls = (urls: string[]) => {
|
||||||
const allowed = urls.slice(0, remainingSlots.value);
|
const newItems: QueueItem[] = urls.map((url) => ({
|
||||||
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),
|
id: Math.random().toString(36).substring(2, 9),
|
||||||
name: url.split('/').pop() || t('upload.queueItem.remoteFileName'),
|
name: url.split('/').pop() || 'Remote File',
|
||||||
type: 'remote',
|
type: 'remote',
|
||||||
status: 'pending',
|
status: 'fetching', // Remote URLs start fetching immediately or pending? User said "khi nao nhan upload". Let's use pending.
|
||||||
progress: 0,
|
progress: 0,
|
||||||
uploaded: '0 MB',
|
uploaded: '0 MB',
|
||||||
total: t('upload.queueItem.unknownSize'),
|
total: 'Unknown',
|
||||||
speed: '0 MB/s',
|
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);
|
items.value.push(...newItems);
|
||||||
return { added: newItems.length, skipped: urls.length - allowed.length, duplicates: duplicateCount };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeItem = (id: string) => {
|
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);
|
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';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,7 +65,7 @@ export function useUploadQueue() {
|
|||||||
items.value.forEach(item => {
|
items.value.forEach(item => {
|
||||||
if (item.status === 'pending') {
|
if (item.status === 'pending') {
|
||||||
if (item.type === 'local') {
|
if (item.type === 'local') {
|
||||||
startChunkUpload(item.id);
|
startMockUpload(item.id);
|
||||||
} else {
|
} else {
|
||||||
startMockRemoteFetch(item.id);
|
startMockRemoteFetch(item.id);
|
||||||
}
|
}
|
||||||
@@ -135,214 +73,57 @@ export function useUploadQueue() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Real Chunk Upload Logic
|
// Mock Upload Logic
|
||||||
const startChunkUpload = async (id: string) => {
|
const startMockUpload = (id: string) => {
|
||||||
const item = items.value.find(i => i.id === id);
|
const item = items.value.find(i => i.id === id);
|
||||||
if (!item || !item.file) return;
|
if (!item) return;
|
||||||
|
|
||||||
item.status = 'uploading';
|
item.status = 'uploading';
|
||||||
item.activeChunks = 0;
|
let progress = 0;
|
||||||
item.uploadedUrls = [];
|
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown
|
||||||
|
|
||||||
const file = item.file;
|
// Random speed between 1MB/s and 5MB/s
|
||||||
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
|
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4);
|
||||||
const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
|
|
||||||
const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i);
|
|
||||||
|
|
||||||
const updateProgress = () => {
|
const interval = setInterval(() => {
|
||||||
let totalUploaded = 0;
|
if (progress >= 100) {
|
||||||
progressMap.forEach(value => {
|
clearInterval(interval);
|
||||||
totalUploaded += value;
|
item.status = 'complete';
|
||||||
});
|
item.progress = 100;
|
||||||
const percent = Math.min((totalUploaded / file.size) * 100, 100);
|
item.uploaded = item.total;
|
||||||
item.progress = parseFloat(percent.toFixed(1));
|
return;
|
||||||
item.uploaded = formatSize(totalUploaded);
|
}
|
||||||
|
|
||||||
// Calculate speed (simplified)
|
// Increment progress randomly
|
||||||
const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0;
|
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';
|
item.speed = formatSize(currentSpeed) + '/s';
|
||||||
};
|
|
||||||
|
|
||||||
const processQueue = async () => {
|
}, 500);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activePromises.length > 0) {
|
|
||||||
await Promise.all(activePromises);
|
|
||||||
await processQueue();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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(t('upload.errors.chunkUploadFailed', { index: 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 || t('upload.errors.mergeFailed'));
|
|
||||||
}
|
|
||||||
|
|
||||||
const playbackUrl = data.playback_url || data.play_url;
|
|
||||||
if (!playbackUrl) {
|
|
||||||
throw new Error('Playback URL missing after merge');
|
|
||||||
}
|
|
||||||
|
|
||||||
const createResponse = await client.videos.videosCreate({
|
|
||||||
title: item.file.name.replace(/\.[^.]+$/, ''),
|
|
||||||
description: '',
|
|
||||||
url: playbackUrl,
|
|
||||||
size: item.file.size,
|
|
||||||
duration: 0,
|
|
||||||
format: item.file.type || 'video/mp4',
|
|
||||||
}, { baseUrl: '/r' });
|
|
||||||
|
|
||||||
const createdVideo = (createResponse.data as any)?.data?.video || (createResponse.data as any)?.data;
|
|
||||||
item.videoId = createdVideo?.id;
|
|
||||||
item.mergeId = data.id;
|
|
||||||
item.playbackUrl = playbackUrl;
|
|
||||||
item.url = playbackUrl;
|
|
||||||
item.status = 'complete';
|
|
||||||
item.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
|
// Mock Remote Fetch Logic
|
||||||
const startMockRemoteFetch = (id: string) => {
|
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;
|
if (!item) return;
|
||||||
|
|
||||||
item.status = 'fetching';
|
item.status = 'fetching'; // Update status to fetching
|
||||||
|
|
||||||
setTimeout(() => {
|
// Remote fetch takes some time then completes
|
||||||
item.status = 'complete';
|
setTimeout(() => {
|
||||||
item.progress = 100;
|
// Switch to uploading/processing phase if we wanted, or just finish
|
||||||
}, 3000 + Math.random() * 3000);
|
item.status = 'complete';
|
||||||
|
item.progress = 100;
|
||||||
|
}, 3000 + Math.random() * 3000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -351,8 +132,7 @@ export function useUploadQueue() {
|
|||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
return `${value} ${sizes[i]}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalSize = computed(() => {
|
const totalSize = computed(() => {
|
||||||
@@ -370,29 +150,15 @@ export function useUploadQueue() {
|
|||||||
const pendingCount = computed(() => {
|
const pendingCount = computed(() => {
|
||||||
return items.value.filter(i => i.status === 'pending').length;
|
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 {
|
return {
|
||||||
items,
|
items,
|
||||||
addFiles,
|
addFiles,
|
||||||
addRemoteUrls,
|
addRemoteUrls,
|
||||||
removeItem,
|
removeItem,
|
||||||
cancelItem,
|
|
||||||
removeAll,
|
|
||||||
startQueue,
|
startQueue,
|
||||||
totalSize,
|
totalSize,
|
||||||
completeCount,
|
completeCount,
|
||||||
pendingCount,
|
pendingCount
|
||||||
remainingSlots,
|
|
||||||
maxItems: MAX_ITEMS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
import { client } from '@/api/client';
|
|
||||||
import { useQuery } from '@pinia/colada';
|
|
||||||
|
|
||||||
export const USAGE_QUERY_KEY = ['usage'] as const;
|
|
||||||
|
|
||||||
export type UsageSnapshot = {
|
|
||||||
totalVideos: number;
|
|
||||||
totalStorage: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UsageResponse = {
|
|
||||||
data?: {
|
|
||||||
total_videos?: number;
|
|
||||||
total_storage?: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const DEFAULT_USAGE_SNAPSHOT: UsageSnapshot = {
|
|
||||||
totalVideos: 0,
|
|
||||||
totalStorage: 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeUsageSnapshot = (responseData: unknown): UsageSnapshot => {
|
|
||||||
const usage = (responseData as UsageResponse | undefined)?.data;
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalVideos: usage?.total_videos ?? DEFAULT_USAGE_SNAPSHOT.totalVideos,
|
|
||||||
totalStorage: usage?.total_storage ?? DEFAULT_USAGE_SNAPSHOT.totalStorage,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export function useUsageQuery() {
|
|
||||||
return useQuery({
|
|
||||||
key: () => USAGE_QUERY_KEY,
|
|
||||||
query: async () => {
|
|
||||||
const response = await client.usage.usageList({ baseUrl: '/r' });
|
|
||||||
return normalizeUsageSnapshot(response.data);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
export const supportedLocales = ['en', 'vi'] as const;
|
|
||||||
|
|
||||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
|
||||||
|
|
||||||
export const defaultLocale: SupportedLocale = 'en';
|
|
||||||
|
|
||||||
export const localeCookieKey = 'i18next';
|
|
||||||
119
src/index.tsx
119
src/index.tsx
@@ -1,25 +1,106 @@
|
|||||||
|
import { renderSSRHead } from '@unhead/vue/server';
|
||||||
import { Hono } from 'hono';
|
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 { createTextTransformStreamClass } from './lib/replateStreamText';
|
||||||
|
import { createApp } from './main';
|
||||||
|
import { useAuthStore } from './stores/auth';
|
||||||
|
|
||||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
const app = new Hono()
|
||||||
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';
|
|
||||||
|
|
||||||
const app = new Hono();
|
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
|
||||||
|
|
||||||
// Global middlewares
|
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||||
setupMiddlewares(app);
|
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 = ''
|
||||||
|
|
||||||
// API proxy middleware (handles /r/*)
|
const headers = new Headers(c.req.header());
|
||||||
app.use(apiProxyMiddleware);
|
headers.delete("host");
|
||||||
// Routes
|
headers.delete("connection");
|
||||||
registerWellKnownRoutes(app);
|
|
||||||
registerMergeRoutes(app);
|
|
||||||
registerDisplayRoutes(app);
|
|
||||||
registerManifestRoutes(app);
|
|
||||||
registerSSRRoutes(app);
|
|
||||||
|
|
||||||
export default app;
|
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();
|
||||||
|
await auth.init();
|
||||||
|
await router.push(url.pathname);
|
||||||
|
await router.isReady();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||||
|
await stream.write("<base href='" + url.origin + "'/>");
|
||||||
|
|
||||||
|
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||||
|
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());
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||||
|
|
||||||
|
function htmlEscape(str: string): string {
|
||||||
|
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app
|
||||||
|
|||||||
@@ -1,91 +0,0 @@
|
|||||||
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),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export interface ITinyMqttClient {
|
|
||||||
connect(): void;
|
|
||||||
disconnect(): void;
|
|
||||||
}
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user