Compare commits
1 Commits
develop-i1
...
develop-ki
| Author | SHA1 | Date | |
|---|---|---|---|
| 7f5bfc7a71 |
@@ -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/form/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/form/Button.vue')['default']
|
||||||
|
Card: typeof import('./src/components/ui/form/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/form/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']
|
Dialog: typeof import('./src/components/ui/form/Dialog.vue')['default']
|
||||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.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/ui/form/Field.vue')['default']
|
||||||
|
Form: typeof import('./src/components/ui/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/form/Input.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']
|
|
||||||
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/form/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/form/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']
|
Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||||
|
Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||||
|
TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||||
|
Toast: typeof import('./src/components/ui/form/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/form/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/form/Button.vue')['default']
|
||||||
|
const Card: typeof import('./src/components/ui/form/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/form/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 Dialog: typeof import('./src/components/ui/form/Dialog.vue')['default']
|
||||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.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/ui/form/Field.vue')['default']
|
||||||
|
const Form: typeof import('./src/components/ui/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/form/Input.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 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/form/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/form/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 Table: typeof import('./src/components/ui/form/Table.vue')['default']
|
||||||
|
const Tag: typeof import('./src/components/ui/form/Tag.vue')['default']
|
||||||
|
const TanStackForm: typeof import('./src/components/ui/form/TanStackForm.vue')['default']
|
||||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||||
|
const Textarea: typeof import('./src/components/ui/form/Textarea.vue')['default']
|
||||||
|
const Toast: typeof import('./src/components/ui/form/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.
45
package.json
45
package.json
@@ -2,42 +2,43 @@
|
|||||||
"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.983.0",
|
||||||
"@hono/node-server": "^1.19.11",
|
"@aws-sdk/s3-presigned-post": "^3.983.0",
|
||||||
"@pinia/colada": "^0.21.7",
|
"@aws-sdk/s3-request-presigner": "^3.983.0",
|
||||||
"@unhead/vue": "^2.1.10",
|
"@hiogawa/utils": "^1.7.0",
|
||||||
"@vueuse/core": "^14.2.1",
|
"@pinia/colada": "^0.21.2",
|
||||||
"aws4fetch": "^1.0.20",
|
"@tanstack/vue-form": "^1.28.0",
|
||||||
|
"@tanstack/vue-table": "^8.21.3",
|
||||||
|
"@unhead/vue": "^2.1.2",
|
||||||
|
"@vueuse/core": "^14.2.0",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"hono": "^4.12.5",
|
"hono": "^4.11.7",
|
||||||
"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": "^3.25.76"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@cloudflare/vite-plugin": "^1.26.0",
|
"@cloudflare/vite-plugin": "^1.23.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.4",
|
"@vitejs/plugin-vue": "^6.0.4",
|
||||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||||
"unocss": "^66.6.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.62.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
1352
src/api/client.ts
1352
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>
|
||||||
|
|||||||
@@ -2,52 +2,55 @@
|
|||||||
import Bell from "@/components/icons/Bell.vue";
|
import Bell from "@/components/icons/Bell.vue";
|
||||||
import Home from "@/components/icons/Home.vue";
|
import Home from "@/components/icons/Home.vue";
|
||||||
import Video from "@/components/icons/Video.vue";
|
import Video from "@/components/icons/Video.vue";
|
||||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
import Credit from "@/components/icons/Credit.vue";
|
||||||
// import Upload from "@/components/icons/Upload.vue";
|
import Upload from "@/components/icons/Upload.vue";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { computed, createStaticVNode, ref } from "vue";
|
|
||||||
import { useTranslation } from 'i18next-vue';
|
|
||||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { createStaticVNode, ref } from "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 } : {}" v-tooltip="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>
|
||||||
|
<ClientOnly>
|
||||||
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
|
<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 class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
|
||||||
|
<path class="stroke-current" fill="none" stroke-width="3"
|
||||||
|
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
|
||||||
</svg>
|
</svg>
|
||||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
|
||||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
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">
|
</div>
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<polyline points="12 6 12 12 16 14" />
|
|
||||||
</svg>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0">
|
<div class="text-left">
|
||||||
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
|
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
|
||||||
<p class="text-xs text-slate-400 leading-tight mt-0.5">
|
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
|
||||||
{{ t('upload.indicator.completeProgress', { complete: completeCount, total: items.length }) }}
|
</div>
|
||||||
</p>
|
<div class="text-xs text-slate-500">
|
||||||
|
{{ completeCount }} / {{ items.length }} files
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Controls -->
|
<div v-if="pendingCount"
|
||||||
<div class="flex items-center gap-1.5 shrink-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">
|
||||||
<!-- Start upload -->
|
{{ pendingCount }}
|
||||||
<button v-if="pendingCount > 0 && !isUploading" @click.stop="startQueue"
|
</div>
|
||||||
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Overall progress bar -->
|
|
||||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
|
||||||
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- File list -->
|
|
||||||
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
|
|
||||||
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
|
|
||||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
|
||||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
|
||||||
<div class="p-3 flex flex-col gap-2">
|
|
||||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
|
|
||||||
@cancel="cancelItem($event)" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</template>
|
</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,9 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import Toast from './ui/form/Toast.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ClientOnly>
|
<Toast />
|
||||||
<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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
37
src/components/ui/form/Avatar.vue
Normal file
37
src/components/ui/form/Avatar.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface AvatarProps {
|
||||||
|
label?: string;
|
||||||
|
shape?: 'circle' | 'square';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<AvatarProps>(), {
|
||||||
|
shape: 'circle',
|
||||||
|
size: 'medium',
|
||||||
|
});
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'w-8 h-8 text-xs',
|
||||||
|
medium: 'w-10 h-10 text-sm',
|
||||||
|
large: 'w-12 h-12 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
const shapeClasses = {
|
||||||
|
circle: 'rounded-full',
|
||||||
|
square: 'rounded-lg',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center justify-center font-medium bg-gray-200 text-gray-600',
|
||||||
|
sizeClasses[size],
|
||||||
|
shapeClasses[shape],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot>
|
||||||
|
{{ label?.charAt(0).toUpperCase() || '?' }}
|
||||||
|
</slot>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
62
src/components/ui/form/Button.vue
Normal file
62
src/components/ui/form/Button.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface ButtonProps {
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
variant?: 'primary' | 'secondary' | 'outlined' | 'text';
|
||||||
|
size?: 'small' | 'medium' | 'large';
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ButtonProps>(), {
|
||||||
|
type: 'button',
|
||||||
|
variant: 'primary',
|
||||||
|
size: 'medium',
|
||||||
|
disabled: false,
|
||||||
|
loading: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
primary: 'bg-primary text-white hover:opacity-90',
|
||||||
|
secondary: 'bg-gray-600 text-white hover:bg-gray-700',
|
||||||
|
outlined: 'border border-gray-300 bg-transparent hover:bg-gray-50',
|
||||||
|
text: 'bg-transparent hover:bg-gray-100',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-3 py-1.5 text-xs',
|
||||||
|
medium: 'px-4 py-2 text-sm',
|
||||||
|
large: '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-primary/20',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
variantClasses[variant],
|
||||||
|
sizeClasses[size],
|
||||||
|
loading ? 'cursor-wait' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
v-if="loading"
|
||||||
|
class="animate-spin -ml-1 mr-2 h-4 w-4"
|
||||||
|
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 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<slot>{{ label }}</slot>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
16
src/components/ui/form/Card.vue
Normal file
16
src/components/ui/form/Card.vue
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface CardProps {
|
||||||
|
cardClass?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
defineProps<CardProps>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="['bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden', cardClass]">
|
||||||
|
<slot name="header" />
|
||||||
|
<div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
66
src/components/ui/form/Checkbox.vue
Normal file
66
src/components/ui/form/Checkbox.vue
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
name: string;
|
||||||
|
value?: any;
|
||||||
|
binary?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<CheckboxProps>(), {
|
||||||
|
binary: false,
|
||||||
|
disabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => formContext?.errors[props.name]);
|
||||||
|
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||||
|
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => {
|
||||||
|
const val = formContext?.values[props.name];
|
||||||
|
if (props.binary) return !!val;
|
||||||
|
return val?.includes(props.value);
|
||||||
|
},
|
||||||
|
set: (val) => {
|
||||||
|
if (props.binary) {
|
||||||
|
formContext?.handleChange(props.name, val);
|
||||||
|
} else {
|
||||||
|
const current = formContext?.values[props.name] || [];
|
||||||
|
if (val) {
|
||||||
|
formContext?.handleChange(props.name, [...current, props.value]);
|
||||||
|
} else {
|
||||||
|
formContext?.handleChange(props.name, current.filter((v: any) => v !== props.value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:id="name + '-' + (value ?? 'binary')"
|
||||||
|
type="checkbox"
|
||||||
|
v-model="modelValue"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary/20',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
|
isInvalid ? 'border-red-500' : '',
|
||||||
|
]"
|
||||||
|
@blur="formContext?.handleBlur(name)"
|
||||||
|
/>
|
||||||
|
<label v-if="$slots.default" :for="name + '-' + (value ?? 'binary')" class="text-sm text-gray-700">
|
||||||
|
<slot />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
115
src/components/ui/form/Dialog.vue
Normal file
115
src/components/ui/form/Dialog.vue
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, onUnmounted, watch } from 'vue';
|
||||||
|
|
||||||
|
interface DialogProps {
|
||||||
|
visible: boolean;
|
||||||
|
header?: string;
|
||||||
|
style?: Record<string, string>;
|
||||||
|
closable?: boolean;
|
||||||
|
modal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<DialogProps>(), {
|
||||||
|
closable: true,
|
||||||
|
modal: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:visible': [value: boolean];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (props.closable) {
|
||||||
|
emit('update:visible', false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBackdropClick = () => {
|
||||||
|
if (props.modal) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape' && props.visible) {
|
||||||
|
handleClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
});
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(() => props.visible, (val) => {
|
||||||
|
if (val) {
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
} else {
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition name="dialog">
|
||||||
|
<div v-if="visible" class="fixed inset-0 z-50 flex items-center justify-center">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 bg-black/50"
|
||||||
|
@click="handleBackdropClick"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dialog -->
|
||||||
|
<div
|
||||||
|
class="relative bg-white rounded-xl shadow-xl w-full max-h-[90vh] overflow-auto"
|
||||||
|
:style="style || { width: '28rem' }"
|
||||||
|
>
|
||||||
|
<!-- Header -->
|
||||||
|
<div v-if="header" class="flex items-center justify-between px-6 py-4 border-b">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">{{ header }}</h3>
|
||||||
|
<button
|
||||||
|
v-if="closable"
|
||||||
|
@click="handleClose"
|
||||||
|
class="p-1 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-6 pt-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dialog-enter-active,
|
||||||
|
.dialog-leave-active {
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from,
|
||||||
|
.dialog-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-active .relative,
|
||||||
|
.dialog-leave-active .relative {
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dialog-enter-from .relative,
|
||||||
|
.dialog-leave-to .relative {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
58
src/components/ui/form/Field.vue
Normal file
58
src/components/ui/form/Field.vue
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface FieldProps {
|
||||||
|
name: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FieldProps>();
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
validators: Record<string, ((value: any) => string | undefined)[]>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => props.name ? formContext?.errors[props.name] : undefined);
|
||||||
|
const isInvalid = computed(() => props.name ? formContext?.touched[props.name] && !!error.value : false);
|
||||||
|
const fieldValue = computed(() => props.name ? formContext?.values[props.name] ?? '' : '');
|
||||||
|
|
||||||
|
const onChange = (value: any) => {
|
||||||
|
if (props.name && formContext) {
|
||||||
|
formContext.handleChange(props.name, value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBlur = () => {
|
||||||
|
if (props.name && formContext) {
|
||||||
|
formContext.handleBlur(props.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provide values to slot
|
||||||
|
const slotProps = {
|
||||||
|
value: fieldValue,
|
||||||
|
error: error,
|
||||||
|
errorMessage: error,
|
||||||
|
isInvalid,
|
||||||
|
name: props.name,
|
||||||
|
onChange,
|
||||||
|
onBlur,
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="field flex flex-col gap-1">
|
||||||
|
<label v-if="label" :for="name" class="text-sm font-medium text-gray-700">
|
||||||
|
{{ label }}
|
||||||
|
</label>
|
||||||
|
<slot v-bind="slotProps" />
|
||||||
|
<div v-if="isInvalid && error" class="text-xs text-red-600 mt-1">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
91
src/components/ui/form/Form.vue
Normal file
91
src/components/ui/form/Form.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { provide, reactive } from 'vue';
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
initialValues?: Record<string, any>;
|
||||||
|
validators?: Record<string, ((value: any) => string | undefined)[]>;
|
||||||
|
formClass?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [values: Record<string, any>];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const errors = reactive<Record<string, string>>({});
|
||||||
|
const touched = reactive<Record<string, boolean>>({});
|
||||||
|
const values = reactive<Record<string, any>>({...props.initialValues});
|
||||||
|
|
||||||
|
// Initialize values
|
||||||
|
if (props.initialValues) {
|
||||||
|
Object.assign(values, props.initialValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validateField = (name: string) => {
|
||||||
|
const value = values[name];
|
||||||
|
const fieldValidators = props.validators?.[name] || [];
|
||||||
|
|
||||||
|
for (const validator of fieldValidators) {
|
||||||
|
const error = validator(value);
|
||||||
|
if (error) {
|
||||||
|
errors[name] = error;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete errors[name];
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAll = () => {
|
||||||
|
const fieldNames = Object.keys(props.validators || {});
|
||||||
|
let isValid = true;
|
||||||
|
|
||||||
|
for (const name of fieldNames) {
|
||||||
|
if (!validateField(name)) {
|
||||||
|
isValid = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Mark all fields as touched
|
||||||
|
const fieldNames = Object.keys(props.validators || {});
|
||||||
|
for (const name of fieldNames) {
|
||||||
|
touched[name] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (validateAll()) {
|
||||||
|
emit('submit', {...values});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = (name: string) => {
|
||||||
|
touched[name] = true;
|
||||||
|
validateField(name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (name: string, value: any) => {
|
||||||
|
values[name] = value;
|
||||||
|
if (touched[name]) {
|
||||||
|
validateField(name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Provide form context to child components
|
||||||
|
provide('form-context', {
|
||||||
|
values,
|
||||||
|
errors,
|
||||||
|
touched,
|
||||||
|
validators: props.validators || {},
|
||||||
|
handleBlur,
|
||||||
|
handleChange,
|
||||||
|
validateField,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit.prevent="handleSubmit" :class="[formClass, 'flex flex-col gap-4 w-full']">
|
||||||
|
<slot />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
55
src/components/ui/form/Input.vue
Normal file
55
src/components/ui/form/Input.vue
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue';
|
||||||
|
|
||||||
|
interface InputProps {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fluid?: boolean;
|
||||||
|
modelValue?: string | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<InputProps>(), {
|
||||||
|
type: 'text',
|
||||||
|
disabled: false,
|
||||||
|
fluid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
// Handle the v-model binding - support both string and computed ref
|
||||||
|
const inputValue = computed(() => {
|
||||||
|
const val = props.modelValue;
|
||||||
|
// Check if it's a ref/computed
|
||||||
|
if (val && typeof val === 'object' && 'value' in val) {
|
||||||
|
return val.value;
|
||||||
|
}
|
||||||
|
return val ?? '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const onInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
emit('update:modelValue', target.value);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
:id="name"
|
||||||
|
:value="inputValue"
|
||||||
|
@input="onInput"
|
||||||
|
:type="type"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors',
|
||||||
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||||
|
fluid ? 'w-full' : '',
|
||||||
|
'border-gray-300',
|
||||||
|
]"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
30
src/components/ui/form/ProgressBar.vue
Normal file
30
src/components/ui/form/ProgressBar.vue
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface ProgressBarProps {
|
||||||
|
value?: number;
|
||||||
|
showValue?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<ProgressBarProps>(), {
|
||||||
|
value: 0,
|
||||||
|
showValue: false,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full">
|
||||||
|
<div
|
||||||
|
v-if="showValue"
|
||||||
|
class="flex justify-between mb-1"
|
||||||
|
>
|
||||||
|
<span class="text-sm font-medium text-gray-700">
|
||||||
|
<slot name="value">{{ Math.round(value) }}%</slot>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 rounded-full h-2 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="bg-primary h-2 rounded-full transition-all duration-300"
|
||||||
|
:style="{ width: `${Math.min(100, Math.max(0, value))}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
src/components/ui/form/Skeleton.vue
Normal file
24
src/components/ui/form/Skeleton.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface SkeletonProps {
|
||||||
|
width?: string;
|
||||||
|
height?: string;
|
||||||
|
borderRadius?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<SkeletonProps>(), {
|
||||||
|
width: '100%',
|
||||||
|
height: '1rem',
|
||||||
|
borderRadius: '0.375rem',
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="animate-pulse bg-gray-200"
|
||||||
|
:style="{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
35
src/components/ui/form/Table.vue
Normal file
35
src/components/ui/form/Table.vue
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
interface TableProps<T> {
|
||||||
|
value: T[];
|
||||||
|
dataKey: string;
|
||||||
|
selection?: T[];
|
||||||
|
tableStyle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<TableProps<T>>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:selection': [value: T[]];
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table :class="['w-full', tableStyle]">
|
||||||
|
<thead>
|
||||||
|
<tr class="border-b border-gray-200 bg-gray-50">
|
||||||
|
<slot name="header" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr
|
||||||
|
v-for="(item, index) in value"
|
||||||
|
:key="String((item as any)[dataKey])"
|
||||||
|
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
|
||||||
|
>
|
||||||
|
<slot name="body" :data="item" :index="index" />
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
src/components/ui/form/Tag.vue
Normal file
31
src/components/ui/form/Tag.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface TagProps {
|
||||||
|
value?: string;
|
||||||
|
severity?: 'success' | 'error' | 'warn' | 'info' | 'secondary';
|
||||||
|
rounded?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TagProps>(), {
|
||||||
|
rounded: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
success: 'bg-green-100 text-green-800',
|
||||||
|
error: 'bg-red-100 text-red-800',
|
||||||
|
warn: 'bg-yellow-100 text-yellow-800',
|
||||||
|
info: 'bg-blue-100 text-blue-800',
|
||||||
|
secondary: 'bg-gray-100 text-gray-800',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center px-2.5 py-0.5 text-xs font-medium',
|
||||||
|
rounded ? 'rounded-full' : 'rounded',
|
||||||
|
severity ? severityClasses[severity] : 'bg-gray-100 text-gray-800',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot>{{ value }}</slot>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
34
src/components/ui/form/TanStackForm.vue
Normal file
34
src/components/ui/form/TanStackForm.vue
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import { useForm } from '@tanstack/vue-form';
|
||||||
|
import { provide } from 'vue';
|
||||||
|
|
||||||
|
interface FormProps {
|
||||||
|
initialValues?: T;
|
||||||
|
onSubmit?: (values: T) => void | Promise<void>;
|
||||||
|
validators?: Record<string, (value: any) => string | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<FormProps>();
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: props.initialValues,
|
||||||
|
onSubmit: async (values) => {
|
||||||
|
await props.onSubmit?.(values.value);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Provide form context to child components
|
||||||
|
provide('tanstack-form', form);
|
||||||
|
provide('tanstack-form-validators', props.validators || {});
|
||||||
|
|
||||||
|
const handleSubmit = (e: Event) => {
|
||||||
|
e.preventDefault();
|
||||||
|
form.handleSubmit();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form @submit="handleSubmit" class="flex flex-col gap-4 w-full">
|
||||||
|
<slot :form="form" />
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
50
src/components/ui/form/Textarea.vue
Normal file
50
src/components/ui/form/Textarea.vue
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, inject } from 'vue';
|
||||||
|
|
||||||
|
interface TextareaProps {
|
||||||
|
name: string;
|
||||||
|
rows?: number;
|
||||||
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
fluid?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<TextareaProps>(), {
|
||||||
|
rows: 3,
|
||||||
|
disabled: false,
|
||||||
|
fluid: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const formContext = inject<{
|
||||||
|
values: Record<string, any>;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
touched: Record<string, boolean>;
|
||||||
|
handleBlur: (name: string) => void;
|
||||||
|
handleChange: (name: string, value: any) => void;
|
||||||
|
} | null>('form-context', null);
|
||||||
|
|
||||||
|
const error = computed(() => formContext?.errors[props.name]);
|
||||||
|
const isInvalid = computed(() => formContext?.touched[props.name] && error.value);
|
||||||
|
const modelValue = computed({
|
||||||
|
get: () => formContext?.values[props.name] ?? '',
|
||||||
|
set: (val) => formContext?.handleChange(props.name, val),
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<textarea
|
||||||
|
:id="name"
|
||||||
|
v-model="modelValue"
|
||||||
|
:rows="rows"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:disabled="disabled"
|
||||||
|
:class="[
|
||||||
|
'px-3 py-2 text-sm border rounded-lg outline-none transition-colors resize-none',
|
||||||
|
'focus:ring-2 focus:ring-primary/20 focus:border-primary',
|
||||||
|
'disabled:bg-gray-100 disabled:text-gray-500 disabled:cursor-not-allowed',
|
||||||
|
fluid ? 'w-full' : '',
|
||||||
|
isInvalid ? 'border-red-500 focus:border-red-500 focus:ring-red-500/20' : 'border-gray-300',
|
||||||
|
]"
|
||||||
|
@blur="formContext?.handleBlur(name)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
104
src/components/ui/form/Toast.vue
Normal file
104
src/components/ui/form/Toast.vue
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { provide, ref } from 'vue';
|
||||||
|
|
||||||
|
interface ToastItem {
|
||||||
|
id: string;
|
||||||
|
severity: 'success' | 'error' | 'warn' | 'info';
|
||||||
|
summary: string;
|
||||||
|
detail?: string;
|
||||||
|
life?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toasts = ref<ToastItem[]>([]);
|
||||||
|
|
||||||
|
const addToast = (toast: Omit<ToastItem, 'id'>) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
const newToast = { ...toast, id };
|
||||||
|
toasts.value.push(newToast);
|
||||||
|
|
||||||
|
const life = toast.life || 5000;
|
||||||
|
setTimeout(() => {
|
||||||
|
removeToast(id);
|
||||||
|
}, life);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeToast = (id: string) => {
|
||||||
|
const index = toasts.value.findIndex(t => t.id === id);
|
||||||
|
if (index > -1) {
|
||||||
|
toasts.value.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toast = {
|
||||||
|
add: addToast,
|
||||||
|
remove: removeToast,
|
||||||
|
};
|
||||||
|
|
||||||
|
provide('toast', toast);
|
||||||
|
|
||||||
|
const severityClasses = {
|
||||||
|
success: 'bg-green-50 text-green-800 border-green-200',
|
||||||
|
error: 'bg-red-50 text-red-800 border-red-200',
|
||||||
|
warn: 'bg-yellow-50 text-yellow-800 border-yellow-200',
|
||||||
|
info: 'bg-blue-50 text-blue-800 border-blue-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const severityIcons = {
|
||||||
|
success: '✓',
|
||||||
|
error: '✕',
|
||||||
|
warn: '⚠',
|
||||||
|
info: 'ℹ',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 max-w-sm">
|
||||||
|
<TransitionGroup name="toast">
|
||||||
|
<div
|
||||||
|
v-for="toast in toasts"
|
||||||
|
:key="toast.id"
|
||||||
|
:class="[
|
||||||
|
'flex items-start gap-3 p-4 rounded-lg border shadow-lg',
|
||||||
|
severityClasses[toast.severity],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<span class="text-lg">{{ severityIcons[toast.severity] }}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="font-medium">{{ toast.summary }}</p>
|
||||||
|
<p v-if="toast.detail" class="text-sm opacity-90 mt-1">{{ toast.detail }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
@click="removeToast(toast.id)"
|
||||||
|
class="p-1 hover:bg-black/5 rounded transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</TransitionGroup>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.toast-enter-active,
|
||||||
|
.toast-leave-active {
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-enter-from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-move {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
15
src/components/ui/form/index.ts
Normal file
15
src/components/ui/form/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
export { default as Avatar } from './Avatar.vue';
|
||||||
|
export { default as Button } from './Button.vue';
|
||||||
|
export { default as Card } from './Card.vue';
|
||||||
|
export { default as Checkbox } from './Checkbox.vue';
|
||||||
|
export { default as Dialog } from './Dialog.vue';
|
||||||
|
export { default as Field } from './Field.vue';
|
||||||
|
export { default as Form } from './Form.vue';
|
||||||
|
export { default as Input } from './Input.vue';
|
||||||
|
export { default as ProgressBar } from './ProgressBar.vue';
|
||||||
|
export { default as Skeleton } from './Skeleton.vue';
|
||||||
|
export { default as Table } from './Table.vue';
|
||||||
|
export { default as Tag } from './Tag.vue';
|
||||||
|
export { default as Textarea } from './Textarea.vue';
|
||||||
|
export { default as Toast } from './Toast.vue';
|
||||||
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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,201 +73,42 @@ 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;
|
|
||||||
});
|
|
||||||
const percent = Math.min((totalUploaded / file.size) * 100, 100);
|
|
||||||
item.progress = parseFloat(percent.toFixed(1));
|
|
||||||
item.uploaded = formatSize(totalUploaded);
|
|
||||||
|
|
||||||
// Calculate speed (simplified)
|
|
||||||
const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0;
|
|
||||||
item.speed = formatSize(currentSpeed) + '/s';
|
|
||||||
};
|
|
||||||
|
|
||||||
const processQueue = async () => {
|
|
||||||
if (item.cancelled) return;
|
|
||||||
|
|
||||||
const activePromises: Promise<void>[] = [];
|
|
||||||
|
|
||||||
while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) {
|
|
||||||
const index = queue.shift()!;
|
|
||||||
item.activeChunks = (item.activeChunks || 0) + 1;
|
|
||||||
|
|
||||||
const promise = uploadChunk(index, file, progressMap, updateProgress, item)
|
|
||||||
.then(() => {
|
|
||||||
item.activeChunks = (item.activeChunks || 0) - 1;
|
|
||||||
});
|
|
||||||
activePromises.push(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
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.status = 'complete';
|
||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
item.uploaded = item.total;
|
item.uploaded = item.total;
|
||||||
item.speed = '0 MB/s';
|
return;
|
||||||
} catch (error) {
|
|
||||||
item.status = 'error';
|
|
||||||
console.error('Merge failed:', error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Increment progress randomly
|
||||||
|
const increment = Math.random() * 5 + 1; // 1-6% increment
|
||||||
|
progress = Math.min(progress + increment, 100);
|
||||||
|
|
||||||
|
item.progress = Math.floor(progress);
|
||||||
|
|
||||||
|
// Calculate uploaded size string
|
||||||
|
const currentBytes = (progress / 100) * totalSize;
|
||||||
|
item.uploaded = formatSize(currentBytes);
|
||||||
|
|
||||||
|
// Re-randomize speed for realism
|
||||||
|
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
|
||||||
|
item.speed = formatSize(currentSpeed) + '/s';
|
||||||
|
|
||||||
|
}, 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mock Remote Fetch Logic
|
// Mock Remote Fetch Logic
|
||||||
@@ -337,9 +116,11 @@ export function useUploadQueue() {
|
|||||||
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
|
||||||
|
|
||||||
|
// Remote fetch takes some time then completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
// Switch to uploading/processing phase if we wanted, or just finish
|
||||||
item.status = 'complete';
|
item.status = 'complete';
|
||||||
item.progress = 100;
|
item.progress = 100;
|
||||||
}, 3000 + Math.random() * 3000);
|
}, 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';
|
|
||||||
112
src/index.tsx
112
src/index.tsx
@@ -1,25 +1,99 @@
|
|||||||
|
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';
|
||||||
|
const app = new Hono()
|
||||||
|
// app.use(renderer)
|
||||||
|
app.use('*', contextStorage());
|
||||||
|
app.use(cors(), async (c, next) => {
|
||||||
|
c.set("fetch", app.request.bind(app));
|
||||||
|
const ua = c.req.header("User-Agent")
|
||||||
|
if (!ua) {
|
||||||
|
return c.json({ error: "User-Agent header is missing" }, 400);
|
||||||
|
};
|
||||||
|
c.set("isMobile", isMobile({ ua }));
|
||||||
|
await next();
|
||||||
|
}, async (c, next) => {
|
||||||
|
const path = c.req.path
|
||||||
|
|
||||||
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
|
if (path !== '/r' && !path.startsWith('/r/')) {
|
||||||
import { setupMiddlewares } from './server/middlewares/setup';
|
return await next()
|
||||||
import { registerDisplayRoutes } from './server/routes/display';
|
}
|
||||||
import { registerManifestRoutes } from './server/routes/manifest';
|
const url = new URL(c.req.url)
|
||||||
import { registerMergeRoutes } from './server/routes/merge';
|
url.host = 'api.pipic.fun'
|
||||||
import { registerSSRRoutes } from './server/routes/ssr';
|
url.protocol = 'https:'
|
||||||
import { registerWellKnownRoutes } from './server/routes/wellKnown';
|
url.pathname = path.replace(/^\/r/, '') || '/'
|
||||||
|
url.port = ''
|
||||||
|
// console.log("url", url.toString())
|
||||||
|
// console.log("c.req.raw", c.req.raw)
|
||||||
|
const headers = new Headers(c.req.header());
|
||||||
|
headers.delete("host");
|
||||||
|
headers.delete("connection");
|
||||||
|
|
||||||
const app = new Hono();
|
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 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);
|
||||||
|
// console.log("ctx: ", );
|
||||||
|
await stream.write("<!DOCTYPE html><html lang='en'><head>");
|
||||||
|
await stream.write("<base href='" + url.origin + "'/>");
|
||||||
|
|
||||||
// Global middlewares
|
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
|
||||||
setupMiddlewares(app);
|
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
|
||||||
|
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
|
||||||
|
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
|
||||||
|
await stream.write('<link rel="icon" href="/favicon.ico" />');
|
||||||
|
await stream.write(buildBootstrapScript());
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
// API proxy middleware (handles /r/*)
|
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
|
||||||
app.use(apiProxyMiddleware);
|
|
||||||
// Routes
|
|
||||||
registerWellKnownRoutes(app);
|
|
||||||
registerMergeRoutes(app);
|
|
||||||
registerDisplayRoutes(app);
|
|
||||||
registerManifestRoutes(app);
|
|
||||||
registerSSRRoutes(app);
|
|
||||||
|
|
||||||
export default app;
|
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();
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import i18next from "i18next";
|
|
||||||
import I18NextHttpBackend, { HttpBackendOptions } from "i18next-http-backend";
|
|
||||||
const backendOptions: HttpBackendOptions = {
|
|
||||||
loadPath: 'http://localhost:5173/locales/{{lng}}/{{ns}}.json',
|
|
||||||
request: (_options, url, _payload, callback) => {
|
|
||||||
fetch(url)
|
|
||||||
.then((res) =>
|
|
||||||
res.json().then((r) => {
|
|
||||||
callback(null, {
|
|
||||||
data: JSON.stringify(r),
|
|
||||||
status: 200,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.catch(() => {
|
|
||||||
callback(null, {
|
|
||||||
status: 500,
|
|
||||||
data: '',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
export const createI18nInstance = (lng: string) => {
|
|
||||||
console.log('Initializing i18n with language:', lng);
|
|
||||||
const i18n = i18next.createInstance();
|
|
||||||
|
|
||||||
i18n
|
|
||||||
.use(I18NextHttpBackend)
|
|
||||||
.init({
|
|
||||||
lng,
|
|
||||||
supportedLngs: ["en", "vi"],
|
|
||||||
fallbackLng: "en",
|
|
||||||
defaultNS: "translation",
|
|
||||||
ns: ['translation'],
|
|
||||||
interpolation: {
|
|
||||||
escapeValue: false,
|
|
||||||
},
|
|
||||||
backend: backendOptions,
|
|
||||||
});
|
|
||||||
return i18n;
|
|
||||||
};
|
|
||||||
export default createI18nInstance;
|
|
||||||
@@ -49,14 +49,14 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export const formatBytes = (bytes?: number) => {
|
export const formatBytes = (bytes?: number) => {
|
||||||
if (!bytes) return '0 B';
|
if (!bytes) return '0 B';
|
||||||
const k = 1024;
|
const k = 1024;
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
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]}`;
|
|
||||||
// return `${new Intl.NumberFormat(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDuration = (seconds?: number) => {
|
export const formatDuration = (seconds?: number) => {
|
||||||
@@ -71,29 +71,22 @@ export const formatDuration = (seconds?: number) => {
|
|||||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
|
export const formatDate = (dateString?: string) => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
const locale = typeof document !== 'undefined'
|
return new Date(dateString).toLocaleDateString('en-US', {
|
||||||
? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US'
|
|
||||||
: 'en-US';
|
|
||||||
return new Date(dateString).toLocaleDateString(locale, {
|
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
...(dateOnly ? {} : { hour: '2-digit', minute: '2-digit' })
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getStatusSeverity = (status: string = "") => {
|
export const getStatusClass = (status?: string) => {
|
||||||
switch (status) {
|
switch (status?.toLowerCase()) {
|
||||||
case 'success':
|
case 'ready': return 'bg-green-100 text-green-700';
|
||||||
case 'ready':
|
case 'processing': return 'bg-yellow-100 text-yellow-700';
|
||||||
return 'success';
|
case 'failed': return 'bg-red-100 text-red-700';
|
||||||
case 'failed':
|
default: return 'bg-gray-100 text-gray-700';
|
||||||
return 'danger';
|
|
||||||
case 'pending':
|
|
||||||
return 'warn';
|
|
||||||
default:
|
|
||||||
return 'info';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
48
src/main.ts
48
src/main.ts
@@ -1,29 +1,15 @@
|
|||||||
import { PiniaColada, useQueryCache } from '@pinia/colada';
|
import { createHead as CSRHead } from "@unhead/vue/client";
|
||||||
import { createHead as CSRHead } from '@unhead/vue/client';
|
import { createHead as SSRHead } from "@unhead/vue/server";
|
||||||
import { createHead as SSRHead } from '@unhead/vue/server';
|
import { createPinia } from "pinia";
|
||||||
import { createPinia } from 'pinia';
|
|
||||||
import { createSSRApp } from 'vue';
|
import { createSSRApp } from 'vue';
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
|
|
||||||
import I18NextVue from 'i18next-vue';
|
|
||||||
|
|
||||||
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
|
||||||
import createI18nInstance from './lib/translation';
|
|
||||||
import createAppRouter from './routes';
|
import createAppRouter from './routes';
|
||||||
|
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
|
||||||
const bodyClass = ':uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen';
|
export function createApp() {
|
||||||
|
|
||||||
const getSerializedAppData = () => {
|
|
||||||
if (typeof document === 'undefined') return {} as Record<string, any>;
|
|
||||||
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function createApp(lng: string = 'en') {
|
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const app = createSSRApp(withErrorBoundary(RouterView));
|
const app = createSSRApp(withErrorBoundary(RouterView));
|
||||||
|
|
||||||
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
|
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
|
||||||
const appData = !import.meta.env.SSR ? getSerializedAppData() : ({} as Record<string, any>);
|
|
||||||
|
|
||||||
app.use(head);
|
app.use(head);
|
||||||
app.directive('nh', {
|
app.directive('nh', {
|
||||||
@@ -31,35 +17,17 @@ export async function createApp(lng: string = 'en') {
|
|||||||
el.__v_skip = true;
|
el.__v_skip = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.use(pinia);
|
|
||||||
app.use(I18NextVue, { i18next: createI18nInstance(lng) });
|
|
||||||
app.use(PiniaColada, {
|
|
||||||
pinia,
|
|
||||||
plugins: [
|
|
||||||
() => {
|
|
||||||
// reserved for query plugins
|
|
||||||
}
|
|
||||||
],
|
|
||||||
queryOptions: {
|
|
||||||
refetchOnMount: false,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
ssrCatchError: true,
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryCache = useQueryCache();
|
|
||||||
|
|
||||||
if (!import.meta.env.SSR) {
|
if (!import.meta.env.SSR) {
|
||||||
Object.entries(appData).forEach(([key, value]) => {
|
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
|
||||||
(window as any)[key] = value;
|
(window as any)[key] = value;
|
||||||
});
|
});
|
||||||
if ((window as any).$p) {
|
if ((window as any).$p) {
|
||||||
pinia.state.value = (window as any).$p;
|
pinia.state.value = (window as any).$p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
app.use(pinia);
|
||||||
const router = createAppRouter();
|
const router = createAppRouter();
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
return { app, router, head, pinia, bodyClass, queryCache };
|
return { app, router, head, pinia, bodyClass };
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user