23 Commits

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

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

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 02:11:46 +00:00
bbe15d5f3e remove vue-i18n 2026-03-06 00:08:51 +07:00
dba9713d96 add change language 2026-03-05 09:21:06 +00:00
e1ba24d1bf refactor: update video components to use AppButton and improve UI consistency
- Refactored CardPopover.vue to enhance menu positioning and accessibility.
- Replaced Button components with AppButton in VideoEditForm.vue and VideoInfoHeader.vue for consistent styling.
- Simplified VideoSkeleton.vue by removing unused Skeleton imports and improving loading states.
- Updated VideoFilters.vue to replace PrimeVue components with native HTML elements for better performance.
- Enhanced VideoGrid.vue and VideoTable.vue with improved selection handling and UI updates.
- Removed unused PrimeVue styles and imports in SSR routes and configuration files.
2026-03-05 01:35:25 +07:00
77ece5224d refactor: replace PrimeVue components with custom App components for buttons, dialogs, and inputs
- Updated DangerZone.vue to use AppButton and AppDialog, replacing PrimeVue Button and Dialog components.
- Refactored DomainsDns.vue to utilize AppButton, AppDialog, and AppInput, enhancing the UI consistency.
- Modified NotificationSettings.vue and PlayerSettings.vue to implement AppButton and AppSwitch for better styling.
- Replaced PrimeVue components in SecurityNConnected.vue with AppButton, AppDialog, and AppInput for a cohesive design.
- Introduced AppConfirmHost for handling confirmation dialogs with a custom design.
- Created AppToastHost for managing toast notifications with custom styling and behavior.
- Added utility composables useAppConfirm and useAppToast for managing confirmation dialogs and toast notifications.
- Implemented AppProgressBar and AppSwitch components for improved UI elements.
2026-03-04 18:32:17 +07:00
16caa9281b feat: enhance settings pages with save functionality and UI improvements
- Added save functionality with toast notifications in NotificationSettings.vue and PlayerSettings.vue.
- Improved layout and styling in NotificationSettings.vue and PlayerSettings.vue for better user experience.
- Refactored PlayerSettings.vue to use a dynamic settingsItems array for rendering toggle switches.
- Updated SecurityNConnected.vue to enhance security settings UI, including two-factor authentication and connected accounts management.
- Introduced dialogs for changing passwords and enabling two-factor authentication with improved UX.
- Added scrollbar-gutter CSS property to prevent layout shifts when dialogs open in uno.config.ts.
2026-03-02 03:34:47 +07:00
cd9aab8979 feat(settings): add Billing, Danger Zone, Domains DNS, Notification, Player, and Security settings pages
- Implemented Billing page with wallet balance, current plan, usage stats, available plans, and payment history.
- Created Danger Zone page for account deletion and data clearing actions with confirmation prompts.
- Developed Domains DNS page for managing whitelisted domains for iframe embedding, including add and remove functionality.
- Added Notification Settings page to configure email, push, marketing, and Telegram notifications.
- Introduced Player Settings page to customize video player behavior such as autoplay, loop, and controls visibility.
- Established Security and Connected Accounts page for managing user profile, two-factor authentication, and connected accounts.
2026-03-01 22:49:30 +07:00
c6924afe5b feat(video): enhance video management UI and functionality
- Refactor VideoBulkActions.vue to remove unused imports.
- Update VideoFilters.vue to improve search and status filtering with new UI components from PrimeVue.
- Modify VideoTable.vue to enhance action buttons for editing, copying, and deleting videos, using PrimeVue Button components.
- Implement saveImageFromStream function in merge.ts to handle thumbnail image uploads.
- Add new animation rule for card spring effect in uno.config.ts.
- Create FileUploadType.vue icon component for local and remote file uploads.
- Introduce CopyVideoModal.vue for sharing video links with enhanced user experience.
- Add DetailVideoModal.vue for editing video details with form validation using Zod.
- Establish new display routes in display.ts for handling thumbnail and metadata updates.
2026-02-27 18:07:43 +07:00
a5b4028bc8 feat: Implement a global upload dialog and refactor the upload UI/UX, including manifest size tracking. 2026-02-27 03:49:54 +07:00
ff1d4902bc Refactor server structure and enhance middleware functionality
- Consolidated middleware setup into a dedicated setup file for better organization.
- Introduced API proxy middleware to handle requests to the backend API.
- Registered well-known, merge, and SSR routes in separate files for improved modularity.
- Removed unused HTML and SSR layout files to streamline the codebase.
- Implemented a utility for HTML escaping to prevent XSS vulnerabilities.
- Updated the main server entry point to utilize the new middleware and route structure.
2026-02-26 18:38:37 +07:00
00bbe0f503 feat(upload): enhance upload functionality with chunk management and cancellation support
- Updated Upload.vue to include cancelItem functionality in the upload queue.
- Modified UploadQueue.vue to emit cancel events for individual items.
- Enhanced UploadQueueItem.vue to display cancel button for ongoing uploads.
- Added merge.ts for handling manifest creation and S3 operations for chunk uploads.
- Introduced temp.html for testing multi-threaded chunk uploads with progress tracking.
- Created AGENTS.md for comprehensive project documentation and guidelines.
2026-02-26 18:14:08 +07:00
d6183d208e docs: update CLAUDE.md with TinyMqttClient and testing info
- Add note about TinyMqttClient being used for MQTT in `src/lib/liteMqtt.ts`
- Add a note mentioning that testing and linting tools are currently not configured

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 01:19:58 +07:00
1a3dc948a8 feat: Add CLAUDE.md with project architecture documentation
Add comprehensive documentation for Claude Code including:
- Common development commands (dev, build, deploy)
- SSR architecture with custom Vite plugin
- State management patterns (Pinia Colada)
- API client auto-generation setup
- Routing structure and auth flow
- Styling system with UnoCSS configuration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-21 00:18:52 +07:00
718554dee9 feat: Add CardPopover component for video actions and integrate EllipsisVerticalIcon 2026-02-14 17:18:22 +07:00
85af2da6ad feat: Introduce TinyMqttClient interface and implementation, update auth store for MQTT connection management 2026-02-08 23:59:48 +07:00
66028d934a feat: Implement TinyMqttClient for MQTT communication and enhance video components with loading states 2026-02-07 21:56:05 +07:00
4d41d6540a feat: Add video detail management components and enhance video state sharing 2026-02-06 18:39:38 +07:00
1ee2130d88 feat: Enhance video management with detailed view and improved routing 2026-02-05 21:48:22 +07:00
27a765044d feat: Implement video management with a data table and comprehensive plan and subscription management features. 2026-02-05 18:38:10 +07:00
184 changed files with 17477 additions and 6606 deletions

View File

@@ -0,0 +1,11 @@
{
"permissions": {
"allow": [
"Bash(bun run build)",
"mcp__ide__getDiagnostics",
"Bash(bun install:*)",
"Bash(bun preview:*)",
"Bash(curl:*)"
]
}
}

374
AGENTS.md Normal file
View File

@@ -0,0 +1,374 @@
# 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 Normal file
View File

@@ -0,0 +1,83 @@
# 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.

1045
bun.lock

File diff suppressed because it is too large Load Diff

100
components.d.ts vendored
View File

@@ -12,103 +12,159 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
Add: typeof import('./src/components/icons/Add.vue')['default']
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
AppButton: typeof import('./src/components/app/AppButton.vue')['default']
AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
AppInput: typeof import('./src/components/app/AppInput.vue')['default']
AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
Bell: typeof import('./src/components/icons/Bell.vue')['default']
Button: typeof import('primevue/button')['default']
BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default']
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
Credit: typeof import('./src/components/icons/Credit.vue')['default']
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
FloatLabel: typeof import('primevue/floatlabel')['default']
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
Globe: typeof import('./src/components/icons/Globe.vue')['default']
GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
Home: typeof import('./src/components/icons/Home.vue')['default']
IconField: typeof import('primevue/iconfield')['default']
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
Message: typeof import('primevue/message')['default']
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
Paginator: typeof import('primevue/paginator')['default']
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
Password: typeof import('primevue/password')['default']
PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default']
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
Skeleton: typeof import('primevue/skeleton')['default']
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default']
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
VueHead: typeof import('./src/components/VueHead.tsx')['default']
WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
}
}
// For TSX support
declare global {
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
const Add: typeof import('./src/components/icons/Add.vue')['default']
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
const AppButton: typeof import('./src/components/app/AppButton.vue')['default']
const AppConfirmHost: typeof import('./src/components/app/AppConfirmHost.vue')['default']
const AppDialog: typeof import('./src/components/app/AppDialog.vue')['default']
const AppInput: typeof import('./src/components/app/AppInput.vue')['default']
const AppProgressBar: typeof import('./src/components/app/AppProgressBar.vue')['default']
const AppSwitch: typeof import('./src/components/app/AppSwitch.vue')['default']
const AppToastHost: typeof import('./src/components/app/AppToastHost.vue')['default']
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.vue')['default']
const Button: typeof import('primevue/button')['default']
const BellIcon: typeof import('./src/components/icons/BellIcon.vue')['default']
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
const Checkbox: typeof import('primevue/checkbox')['default']
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
const FloatLabel: typeof import('primevue/floatlabel')['default']
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
const Globe: typeof import('./src/components/icons/Globe.vue')['default']
const GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
const Home: typeof import('./src/components/icons/Home.vue')['default']
const IconField: typeof import('primevue/iconfield')['default']
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
const InputIcon: typeof import('primevue/inputicon')['default']
const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const Message: typeof import('primevue/message')['default']
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.vue')['default']
const NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
const PageHeader: typeof import('./src/components/dashboard/PageHeader.vue')['default']
const Paginator: typeof import('primevue/paginator')['default']
const PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['default']
const Password: typeof import('primevue/password')['default']
const PencilIcon: typeof import('./src/components/icons/PencilIcon.vue')['default']
const PlayIcon: typeof import('./src/components/icons/PlayIcon.vue')['default']
const PlusIcon: typeof import('./src/components/icons/PlusIcon.vue')['default']
const PlusSquareIcon: typeof import('./src/components/icons/PlusSquareIcon.vue')['default']
const RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView']
const Select: typeof import('primevue/select')['default']
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
const Skeleton: typeof import('primevue/skeleton')['default']
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default']
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
const VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
}

3223
docs.json

File diff suppressed because it is too large Load Diff

BIN
golang.tar.gz Normal file

Binary file not shown.

View File

@@ -2,45 +2,42 @@
"name": "holistream",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"dev": "bun vite",
"build": "bun vite build",
"preview": "bun vite preview",
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"tail": "wrangler tail"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.971.0",
"@aws-sdk/s3-presigned-post": "^3.971.0",
"@aws-sdk/s3-request-presigner": "^3.971.0",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0",
"@primeuix/themes": "^2.0.3",
"@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.2",
"@vueuse/core": "^14.1.0",
"@hattip/adapter-node": "^0.0.49",
"@hono/node-server": "^1.19.11",
"@pinia/colada": "^0.21.7",
"@unhead/vue": "^2.1.10",
"@vueuse/core": "^14.2.1",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1",
"firebase-admin": "^13.6.0",
"hono": "^4.11.4",
"hono": "^4.12.5",
"i18next": "^25.8.14",
"i18next-http-backend": "^3.0.2",
"i18next-vue": "^5.4.0",
"is-mobile": "^5.0.0",
"pinia": "^3.0.4",
"primevue": "^4.5.4",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.27",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
"tailwind-merge": "^3.5.0",
"vue": "^3.5.29",
"vue-router": "^5.0.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@cloudflare/vite-plugin": "^1.21.0",
"@primevue/auto-import-resolver": "^4.5.4",
"@types/node": "^25.0.9",
"@vitejs/plugin-vue": "^6.0.3",
"@vitejs/plugin-vue-jsx": "^5.1.3",
"unocss": "^66.6.0",
"@cloudflare/vite-plugin": "^1.26.0",
"@types/node": "^25.3.3",
"@vitejs/plugin-vue": "^6.0.4",
"@vitejs/plugin-vue-jsx": "^5.1.4",
"unocss": "^66.6.5",
"unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1",
"vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2",
"wrangler": "^4.59.2"
"wrangler": "^4.70.0"
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,31 +1,125 @@
import { tryGetContext } from "hono/context-storage";
import { tryGetContext } from 'hono/context-storage';
export const customFetch = (url: string, options: RequestInit) => {
options.credentials = "include";
// export const baseAPIURL = 'https://api.pipic.fun';
export const baseAPIURL = 'http://localhost:8080';
type RequestOptions = RequestInit | { raw: Request };
const isRequest = (input: URL | RequestInfo): input is Request =>
typeof Request !== 'undefined' && input instanceof Request;
const isRequestLikeOptions = (options: RequestOptions): options is { raw: Request } =>
typeof options === 'object' && options !== null && 'raw' in options && options.raw instanceof Request;
const resolveInputUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
if (input instanceof URL) return new URL(input.toString());
if (isRequest(input)) return new URL(input.url);
const baseUrl = new URL(currentRequestUrl);
baseUrl.pathname = '/';
baseUrl.search = '';
baseUrl.hash = '';
return new URL(input, baseUrl);
};
const resolveApiUrl = (input: URL | RequestInfo, currentRequestUrl: string) => {
const inputUrl = resolveInputUrl(input, currentRequestUrl);
const apiUrl = new URL(baseAPIURL);
apiUrl.pathname = inputUrl.pathname.replace(/^\/?r(?=\/|$)/, '') || '/';
apiUrl.search = inputUrl.search;
apiUrl.hash = inputUrl.hash;
return apiUrl;
};
const getOptionHeaders = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.headers : options.headers;
const getOptionMethod = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.method : options.method;
const getOptionBody = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.body : options.body;
const getOptionSignal = (options: RequestOptions) =>
isRequestLikeOptions(options) ? options.raw.signal : options.signal;
const getOptionCredentials = (options: RequestOptions) =>
isRequestLikeOptions(options) ? undefined : options.credentials;
const mergeHeaders = (input: URL | RequestInfo, options: RequestOptions) => {
const c = tryGetContext<any>();
const mergedHeaders = new Headers(c?.req.raw.headers ?? undefined);
const inputHeaders = isRequest(input) ? input.headers : undefined;
const optionHeaders = getOptionHeaders(options);
new Headers(inputHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
});
new Headers(optionHeaders).forEach((value, key) => {
mergedHeaders.set(key, value);
});
mergedHeaders.delete('host');
mergedHeaders.delete('connection');
mergedHeaders.delete('content-length');
mergedHeaders.delete('transfer-encoding');
return mergedHeaders;
};
const resolveMethod = (input: URL | RequestInfo, options: RequestOptions) => {
const method = getOptionMethod(options);
if (method) return method;
if (isRequest(input)) return input.method;
return 'GET';
};
const resolveBody = (input: URL | RequestInfo, options: RequestOptions, method: string) => {
if (method === 'GET' || method === 'HEAD') return undefined;
const body = getOptionBody(options);
if (typeof body !== 'undefined') return body;
if (isRequest(input)) return input.body;
return undefined;
};
export const customFetch = (input: URL | RequestInfo, options: RequestOptions = {}) => {
const c = tryGetContext<any>();
if (!c) {
throw new Error("Hono context not found in SSR");
throw new Error('Hono context not found in SSR');
}
// Merge headers properly - keep original options.headers and add request headers
const reqHeaders = new Headers(c.req.header());
// Remove headers that shouldn't be forwarded
reqHeaders.delete("host");
reqHeaders.delete("connection");
const mergedHeaders: Record<string, string> = {};
reqHeaders.forEach((value, key) => {
mergedHeaders[key] = value;
});
options.headers = {
...mergedHeaders,
...(options.headers as Record<string, string>),
const apiUrl = resolveApiUrl(input, c.req.url);
const method = resolveMethod(input, options);
const body = resolveBody(input, options, method.toUpperCase());
const requestOptions: RequestInit & { duplex?: 'half' } = {
...(isRequestLikeOptions(options) ? {} : options),
method,
headers: mergeHeaders(input, options),
body,
credentials: getOptionCredentials(options) ?? 'include',
signal: getOptionSignal(options) ?? (isRequest(input) ? input.signal : undefined),
};
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join("");
return fetch(apiUrl, options).then(async (res) => {
res.headers.getSetCookie()?.forEach((cookie) => {
c.header("Set-Cookie", cookie);
});
return res;
if (body) {
requestOptions.duplex = 'half';
}
return fetch(apiUrl, requestOptions).then((response) => {
const setCookies = typeof response.headers.getSetCookie === 'function'
? response.headers.getSetCookie()
: response.headers.get('set-cookie')
? [response.headers.get('set-cookie')!]
: [];
for (const cookie of setCookies) {
c.header('Set-Cookie', cookie, { append: true });
}
return response;
});
};

View File

@@ -1,22 +0,0 @@
import { getContext } from "hono/context-storage";
import { HonoVarTypes } from "types";
// We can keep checkAuth to return the current user profile from the context
// which is populated by the firebaseAuthMiddleware
async function checkAuth() {
const context = getContext<HonoVarTypes>();
const user = context.get('user');
if (!user) {
return { authenticated: false, user: null };
}
return {
authenticated: true,
user: user
};
}
export const authMethods = {
checkAuth,
};

View File

@@ -1 +0,0 @@
export const secret = "123_it-is-very-secret_123";

View File

@@ -1,344 +0,0 @@
import {
exposeTinyRpc,
httpServerAdapter,
validateFn,
} from "@hiogawa/tiny-rpc";
import { tinyassert } from "@hiogawa/utils";
import { MiddlewareHandler, type Context, type Next } from "hono";
import { getContext } from "hono/context-storage";
// import { adminAuth } from "../../lib/firebaseAdmin";
import { z } from "zod";
import { authMethods } from "./auth";
import { abortChunk, chunkedUpload, completeChunk, createPresignedUrls, imageContentTypes, nanoid, presignedPut, videoContentTypes } from "./s3_handle";
// import { createElement } from "react";
let counter = 0;
const listCourses = [
{
id: 1,
title: "Lập trình Web Fullstack",
description:
"Học cách xây dựng ứng dụng web hoàn chỉnh từ frontend đến backend. Khóa học bao gồm HTML, CSS, JavaScript, React, Node.js và MongoDB.",
category: "Lập trình",
rating: 4.9,
price: "1.200.000 VNĐ",
icon: "fas fa-code",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Web%20Fullstack",
slug: "lap-trinh-web-fullstack",
},
{
id: 2,
title: "Phân tích dữ liệu với Python",
description:
"Khám phá sức mạnh của Python trong việc phân tích và trực quan hóa dữ liệu. Sử dụng Pandas, NumPy, Matplotlib và Seaborn.",
category: "Phân tích dữ liệu",
rating: 4.8,
price: "900.000 VNĐ",
icon: "fas fa-chart-bar",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Data%20Analysis",
slug: "phan-tich-du-lieu-voi-python",
},
{
id: 3,
title: "Thiết kế UI/UX chuyên nghiệp",
description:
"Học các nguyên tắc thiết kế giao diện và trải nghiệm người dùng hiện đại. Sử dụng Figma và Adobe XD.",
category: "Thiết kế",
rating: 4.7,
price: "800.000 VNĐ",
icon: "fas fa-paint-brush",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=UI/UX%20Design",
slug: "thiet-ke-ui-ux-chuyen-nghiep",
},
{
id: 4,
title: "Machine Learning cơ bản",
description:
"Nhập môn Machine Learning với Python. Tìm hiểu về các thuật toán học máy cơ bản như Linear Regression, Logistic Regression, Decision Trees.",
category: "AI/ML",
rating: 4.6,
price: "1.500.000 VNĐ",
icon: "fas fa-brain",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Machine%20Learning",
slug: "machine-learning-co-ban",
},
{
id: 5,
title: "Digital Marketing toàn diện",
description:
"Chiến lược Marketing trên các nền tảng số. SEO, Google Ads, Facebook Ads và Content Marketing.",
category: "Marketing",
rating: 4.5,
price: "700.000 VNĐ",
icon: "fas fa-bullhorn",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Digital%20Marketing",
slug: "digital-marketing-toan-dien",
},
{
id: 6,
title: "Lập trình Mobile với Flutter",
description:
"Xây dựng ứng dụng di động đa nền tảng (iOS & Android) với Flutter và Dart.",
category: "Lập trình",
rating: 4.8,
price: "1.100.000 VNĐ",
icon: "fas fa-mobile-alt",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Flutter%20Mobile",
slug: "lap-trinh-mobile-voi-flutter",
},
{
id: 7,
title: "Tiếng Anh giao tiếp công sở",
description:
"Cải thiện kỹ năng giao tiếp tiếng Anh trong môi trường làm việc chuyên nghiệp.",
category: "Ngoại ngữ",
rating: 4.4,
price: "600.000 VNĐ",
icon: "fas fa-language",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Business%20English",
slug: "tieng-anh-giao-tiep-cong-so",
},
{
id: 8,
title: "Quản trị dự án Agile/Scrum",
description:
"Phương pháp quản lý dự án linh hoạt Agile và khung làm việc Scrum.",
category: "Kỹ năng mềm",
rating: 4.7,
price: "950.000 VNĐ",
icon: "fas fa-tasks",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Agile%20Scrum",
slug: "quan-tri-du-an-agile-scrum",
},
{
id: 9,
title: "Nhiếp ảnh cơ bản",
description:
"Làm chủ máy ảnh và nghệ thuật nhiếp ảnh. Bố cục, ánh sáng và chỉnh sửa ảnh.",
category: "Nghệ thuật",
rating: 4.9,
price: "500.000 VNĐ",
icon: "fas fa-camera",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Photography",
slug: "nhiep-anh-co-ban",
},
{
id: 10,
title: "Blockchain 101",
description:
"Hiểu về công nghệ Blockchain, Bitcoin, Ethereum và Smart Contracts.",
category: "Công nghệ",
rating: 4.6,
price: "1.300.000 VNĐ",
icon: "fas fa-link",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Blockchain",
slug: "blockchain-101",
},
{
id: 11,
title: "ReactJS Nâng cao",
description:
"Các kỹ thuật nâng cao trong React: Hooks, Context, Redux, Performance Optimization.",
category: "Lập trình",
rating: 4.9,
price: "1.000.000 VNĐ",
icon: "fas fa-code",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Advanced%20React",
slug: "reactjs-nang-cao",
},
{
id: 12,
title: "Viết Content Marketing thu hút",
description:
"Kỹ thuật viết bài chuẩn SEO, thu hút người đọc và tăng tỷ lệ chuyển đổi.",
category: "Marketing",
rating: 4.5,
price: "550.000 VNĐ",
icon: "fas fa-pen-nib",
bgImg: "https://placehold.co/600x400/EEE/31343C?font=playfair-display&text=Content%20Marketing",
slug: "viet-content-marketing",
}
];
const courseContent = [
{
id: 1,
title: "Giới thiệu khóa học",
type: "video",
duration: "5:00",
completed: true,
},
{
id: 2,
title: "Cài đặt môi trường",
type: "video",
duration: "15:00",
completed: false,
},
{
id: 3,
title: "Kiến thức cơ bản",
type: "video",
duration: "25:00",
completed: false,
},
{
id: 4,
title: "Bài tập thực hành 1",
type: "quiz",
duration: "10:00",
completed: false,
},
];
const routes = {
// define as a bare function
checkId: (id: string) => {
const context = getContext();
console.log(context.req.raw.headers);
return id === "good";
},
checkIdThrow: (id: string) => {
tinyassert(id === "good", "Invalid ID");
return null;
},
getCounter: () => {
const context = getContext();
console.log(context.get("jwtPayload"));
return counter;
},
// define with zod validation + input type inference
incrementCounter: validateFn(z.object({ delta: z.number().default(1) }))(
(input) => {
// expectTypeOf(input).toEqualTypeOf<{ delta: number }>();
counter += input.delta;
return counter;
}
),
// access context
components: async () => { },
getHomeCourses: async () => {
return listCourses.slice(0, 3);
},
getCourses: validateFn(
z.object({
page: z.number().default(1),
limit: z.number().default(6),
search: z.string().optional(),
category: z.string().optional(),
})
)(async ({ page, limit, search, category }) => {
let filtered = listCourses;
if (search) {
const lowerSearch = search.toLowerCase();
filtered = filtered.filter(
(c) =>
c.title.toLowerCase().includes(lowerSearch) ||
c.description.toLowerCase().includes(lowerSearch)
);
}
if (category && category !== "All") {
filtered = filtered.filter((c) => c.category === category);
}
const start = (page - 1) * limit;
const end = start + limit;
const paginated = filtered.slice(start, end);
return {
data: paginated,
total: filtered.length,
page,
totalPages: Math.ceil(filtered.length / limit),
};
}),
getCourseBySlug: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
const course = listCourses.find((c) => c.slug === slug);
if (!course) {
throw new Error("Course not found");
}
return course;
}),
getCourseContent: validateFn(z.object({ slug: z.string() }))(async ({ slug }) => {
// In a real app, we would fetch content specific to the course
return courseContent;
}),
presignedPut: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => imageContentTypes.includes(val), { message: "Invalid content type" }) }))(async ({ fileName, contentType }) => {
return await presignedPut(fileName, contentType);
}),
chunkedUpload: validateFn(z.object({ fileName: z.string(), contentType: z.string().refine((val) => videoContentTypes.includes(val), { message: "Invalid content type" }), fileSize: z.number().min(1024 * 10).max(3 * 1024 * 1024 * 1024).default(1024 * 256) }))(async ({ fileName, contentType, fileSize }) => {
const key = nanoid() + "_" + fileName;
const { UploadId } = await chunkedUpload(key, contentType, fileSize);
const chunkSize = 1024 * 1024 * 20; // 20MB
const presignedUrls = await createPresignedUrls({
key,
uploadId: UploadId!,
totalParts: Math.ceil(fileSize / chunkSize),
});
return { uploadId: UploadId!, presignedUrls, chunkSize, key, totalParts: presignedUrls.length };
}),
completeChunk: validateFn(z.object({ key: z.string(), uploadId: z.string(), parts: z.array(z.object({ PartNumber: z.number(), ETag: z.string() })) }))(async ({ key, uploadId, parts }) => {
await completeChunk(key, uploadId, parts);
return { success: true };
}),
abortChunk: validateFn(z.object({ key: z.string(), uploadId: z.string() }))(async ({ key, uploadId }) => {
await abortChunk(key, uploadId);
return { success: true };
}),
...authMethods
};
export type RpcRoutes = typeof routes;
export const endpoint = "/rpc";
export const pathsForGET: (keyof typeof routes)[] = ["getCounter"];
export const firebaseAuthMiddleware: MiddlewareHandler = async (c, next) => {
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent"];
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
c.set("isPublic", isPublic);
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
return await next();
}
const authHeader = c.req.header("Authorization");
if (!authHeader || !authHeader.startsWith("Bearer ")) {
// Option: return 401 or let it pass with no user?
// Old logic seemed to require it for non-public paths.
return c.json({ error: "Unauthorized" }, 401);
}
const token = authHeader.split("Bearer ")[1];
try {
// const decodedToken = await adminAuth.verifyIdToken(token);
// c.set("user", decodedToken);
} catch (error) {
console.error("Firebase Auth Error:", error);
return c.json({ error: "Unauthorized" }, 401);
}
return await next();
}
export const rpcServer = async (c: Context, next: Next) => {
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
return await next();
}
const cert = c.req.header()
console.log("RPC Request Path:", c.req.raw.cf);
// if (!cert) return c.text('Forbidden', 403)
const handler = exposeTinyRpc({
routes,
adapter: httpServerAdapter({ endpoint }),
});
const res = await handler({ request: c.req.raw });
if (res) {
return res;
}
return await next();
};

View File

@@ -1,198 +0,0 @@
import {
S3Client,
ListBucketsCommand,
ListObjectsV2Command,
GetObjectCommand,
PutObjectCommand,
DeleteObjectCommand,
CreateMultipartUploadCommand,
UploadPartCommand,
AbortMultipartUploadCommand,
CompleteMultipartUploadCommand,
ListPartsCommand,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
import { randomBytes } from "crypto";
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
export function nanoid(size = 21) {
let id = '';
const bytes = randomBytes(size); // Node.js specific method
for (let i = 0; i < size; i++) {
id += urlAlphabet[bytes[i] & 63];
}
return id;
}
// createPresignedPost
const S3 = new S3Client({
region: "auto", // Required by SDK but not used by R2
endpoint: `https://s3.cloudfly.vn`,
credentials: {
// accessKeyId: "Q3AM3UQ867SPQQA43P2F",
// secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
accessKeyId: "BD707P5W8J5DHFPUKYZ6",
secretAccessKey: "LTX7IizSDn28XGeQaHNID2fOtagfLc6L2henrP6P",
},
forcePathStyle: true,
});
// const S3 = new S3Client({
// region: "auto", // Required by SDK but not used by R2
// endpoint: `https://u.pipic.fun`,
// credentials: {
// // accessKeyId: "Q3AM3UQ867SPQQA43P2F",
// // secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
// accessKeyId: "cdnadmin",
// secretAccessKey: "D@tkhong9",
// },
// forcePathStyle: true,
// });
export const imageContentTypes = ["image/png", "image/jpg", "image/jpeg", "image/webp"];
export const videoContentTypes = ["video/mp4", "video/webm", "video/ogg", "video/*"];
const nanoId = () => {
// return crypto.randomUUID().replace(/-/g, "").slice(0, 10);
return ""
}
export async function presignedPut(fileName: string, contentType: string){
if (!imageContentTypes.includes(contentType)) {
throw new Error("Invalid content type");
}
const key = nanoId()+"_"+fileName;
const url = await getSignedUrl(
S3,
new PutObjectCommand({
Bucket: "tmp",
Key: key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
// ContentLength: 31457280, // Max 30MB
// ACL: "public-read", // Uncomment if you want the object to be publicly readable
}),
{ expiresIn: 600 } // URL valid for 10 minutes
);
return { url, key };
}
export async function createPresignedUrls({
key,
uploadId,
totalParts,
expiresIn = 60 * 15, // 15 phút
}: {
key: string;
uploadId: string;
totalParts: number;
expiresIn?: number;
}) {
const urls = [];
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
const command = new UploadPartCommand({
Bucket: "tmp",
Key: key,
UploadId: uploadId,
PartNumber: partNumber,
});
const url = await getSignedUrl(S3, command, {
expiresIn,
});
urls.push({
partNumber,
url,
});
}
return urls;
}
export async function chunkedUpload(Key: string, contentType: string, fileSize: number) {
// lớn hơn 3gb thì cút
if (fileSize > 3 * 1024 * 1024 * 1024) {
throw new Error("File size exceeds 3GB");
}
// CreateMultipartUploadCommand
const uploadParams = {
Bucket: "tmp",
Key,
ContentType: contentType,
CacheControl: "public, max-age=31536000, immutable",
};
let data = await S3.send(new CreateMultipartUploadCommand(uploadParams));
return data;
}
export async function abortChunk(key: string, uploadId: string) {
await S3.send(
new AbortMultipartUploadCommand({
Bucket: "tmp",
Key: key,
UploadId: uploadId,
})
);
}
export async function completeChunk(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
const listed = await S3.send(
new ListPartsCommand({
Bucket: "tmp",
Key: key,
UploadId: uploadId,
})
);
if (!listed.Parts || listed.Parts.length !== parts.length) {
throw new Error("Not all parts have been uploaded");
}
await S3.send(
new CompleteMultipartUploadCommand({
Bucket: "tmp",
Key: key,
UploadId: uploadId,
MultipartUpload: {
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
},
})
);
}
export async function deleteObject(bucketName: string, objectKey: string) {
await S3.send(
new DeleteObjectCommand({
Bucket: bucketName,
Key: objectKey,
})
);
}
export async function listBuckets() {
const data = await S3.send(new ListBucketsCommand({}));
return data.Buckets;
}
export async function listObjects(bucketName: string) {
const data = await S3.send(
new ListObjectsV2Command({
Bucket: bucketName,
})
);
return data.Contents;
}
export async function generateUploadForm(fileName: string, contentType: string) {
if (!imageContentTypes.includes(contentType)) {
throw new Error("Invalid content type");
}
return await createPresignedPost(S3, {
Bucket: "tmp",
Key: nanoId()+"_"+fileName,
Expires: 10 * 60, // URL valid for 10 minutes
Conditions: [
["starts-with", "$Content-Type", contentType],
["content-length-range", 0, 31457280], // Max 30MB
],
});
}
// generateUploadUrl("tmp", "cat.png", "image/png").then(console.log);
export async function createDownloadUrl(key: string): Promise<string> {
const url = await getSignedUrl(
S3,
new GetObjectCommand({ Bucket: "tmp", Key: key }),
{ expiresIn: 600 } // 600 giây = 10 phút
);
return url;
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import DashboardNav from "./DashboardNav.vue";
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
import Upload from "@/routes/upload/Upload.vue";
</script>
@@ -20,5 +21,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
</router-view>
</div>
<GlobalUploadIndicator />
<Upload />
</main>
</template>

View File

@@ -2,55 +2,52 @@
import Bell from "@/components/icons/Bell.vue";
import Home from "@/components/icons/Home.vue";
import Video from "@/components/icons/Video.vue";
import Credit from "@/components/icons/Credit.vue";
import Upload from "@/components/icons/Upload.vue";
import NotificationDrawer from "./NotificationDrawer.vue";
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
// import Upload from "@/components/icons/Upload.vue";
import { cn } from "@/lib/utils";
import { createStaticVNode, ref } from "vue";
import { computed, createStaticVNode, ref } from "vue";
import { useTranslation } from 'i18next-vue';
import NotificationDrawer from "./NotificationDrawer.vue";
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
const profileHoist = createStaticVNode(`<div class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated">
<img class="h-8 w-8 rounded-full m-a ring-1 ring-white"
src="https://picsum.photos/seed/user123/40/40.jpg" alt="User avatar" />
</div>`, 1);
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
const isNotificationOpen = ref(false);
const { t } = useTranslation();
const handleNotificationClick = (event: Event) => {
notificationPopover.value?.toggle(event);
};
const links = [
const links = computed(() => [
{ href: "/#home", label: "app", icon: homeHoist, type: "btn", className },
{ href: "/", label: "Overview", icon: Home, type: "a", className },
{ href: "/upload", label: "Upload", icon: Upload, type: "a", className },
{ href: "/video", label: "Video", icon: Video, type: "a", className },
{ href: "/payments-and-plans", label: "Payments & Plans", icon: Credit, type: "a", className },
{ href: "/notification", label: "Notification", icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
{ href: "/profile", label: "Profile", icon: profileHoist, type: "a", className: 'w-12 h-12 rounded-2xl hover:bg-primary/15 flex shrink-0' },
];
{ href: "/", label: t('nav.overview'), icon: Home, type: "a", className },
// { href: "/upload", label: t('common.upload'), icon: Upload, type: "a", className },
{ href: "/videos", label: t('nav.videos'), icon: Video, type: "a", className },
{ href: "/notification", label: t('nav.notification'), icon: Bell, type: "btn", className, action: handleNotificationClick, isActive: isNotificationOpen },
{ href: "/settings", label: t('nav.settings'), icon: SettingsIcon, type: "a", className },
]);
//v-tooltip="i.label"
</script>
<template>
<header
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-muted transition-all duration-300 ease-in-out w-18 items-center">
<template v-for="i in links" :key="i.label">
<template v-for="i in links" :key="i.href">
<component :name="i.label" :is="i.type === 'a' ? 'router-link' : 'div'"
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label" @click="i.action && i.action($event)"
v-bind="i.type === 'a' ? { to: i.href } : {}"
@click="i.action && i.action($event)"
:class="cn(
i.className,
($route.path === i.href || i.isActive?.value) && 'bg-primary/15'
($route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value) && 'bg-primary/15'
)">
<component :is="i.icon" class="w-6 h-6 shrink-0"
:filled="$route.path === i.href || i.isActive?.value" />
:filled="$route.path === i.href || $route.path.startsWith(i.href+'/') || i.isActive?.value" />
</component>
</template>
</header>
<ClientOnly>
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
</ClientOnly>
<NotificationDrawer ref="notificationPopover" @change="(val) => isNotificationOpen = val" />
</template>

View File

@@ -1,103 +1,154 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { useUploadQueue } from '@/composables/useUploadQueue';
import { useRoute, useRouter } from 'vue-router';
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
import { useUIState } from '@/stores/uiState';
import { computed, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { useRouter } from 'vue-router';
const { items, totalSize, completeCount, pendingCount } = useUploadQueue();
const route = useRoute();
const router = useRouter();
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
const uiState = useUIState();
const { t } = useTranslation();
const isOpen = ref(false);
const isCollapsed = ref(false);
const isVisible = computed(() => {
// Show if there are items AND we are NOT on the upload page
return items.value.length > 0 && route.path !== '/upload';
});
const isVisible = computed(() => items.value.length > 0);
const progress = computed(() => {
const overallProgress = computed(() => {
if (items.value.length === 0) return 0;
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
return Math.round(totalProgress / items.value.length);
const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
return Math.round(total / items.value.length);
});
const isUploading = computed(() => {
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching');
const isUploading = computed(() =>
items.value.some(i => i.status === 'uploading' || i.status === 'fetching' || i.status === 'processing')
);
const isAllDone = computed(() =>
items.value.length > 0 && items.value.every(i => i.status === 'complete' || i.status === 'error')
);
const statusText = computed(() => {
if (isAllDone.value) return 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>
<template>
<div v-if="isVisible" class="fixed bottom-6 right-6 z-50 flex flex-col items-end gap-2">
<Transition enter-active-class="transition-all duration-300 ease-out" enter-from-class="opacity-0 translate-y-4"
enter-to-class="opacity-100 translate-y-0" leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 translate-y-0" leave-to-class="opacity-0 translate-y-4">
<!-- Mini Queue Popover -->
<Transition enter-active-class="transition duration-200 ease-out"
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100"
leave-active-class="transition duration-150 ease-in" leave-from-class="opacity-100 translate-y-0 scale-100"
leave-to-class="opacity-0 translate-y-2 scale-95">
<div v-if="isOpen"
class="bg-white rounded-2xl shadow-xl border border-gray-100 p-4 mb-2 w-80 max-h-[60vh] flex flex-col">
<div class="flex items-center justify-between mb-3 pb-3 border-b border-gray-100">
<h3 class="font-bold text-slate-800">Uploads</h3>
<button @click="goToUploadPage" class="text-xs font-bold text-accent hover:underline">
View All
</button>
</div>
<div v-if="isVisible"
class="fixed bottom-6 right-6 z-50 w-96 rounded-2xl bg-white shadow-[0_8px_40px_rgba(0,0,0,0.16)] border border-slate-100 overflow-hidden flex flex-col"
style="max-height: 540px;">
<div
class="flex-1 overflow-y-auto min-h-0 space-y-3 [&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-track]:bg-transparent [&::-webkit-scrollbar-thumb]:bg-slate-300 [&::-webkit-scrollbar-thumb]:rounded">
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true"
class="border-b border-slate-100 last:border-0 !rounded-none" />
</div>
</div>
</Transition>
<!-- Header bar -->
<div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
@click="isCollapsed = !isCollapsed">
<!-- Floating Button -->
<button @click="toggleOpen"
class="relative flex items-center gap-3 bg-white pl-4 pr-5 py-3 rounded-full shadow-[0_8px_30px_rgba(0,0,0,0.12)] border border-slate-100 hover:-translate-y-1 transition-all duration-300 group">
<!-- Progress Ring -->
<div class="relative w-10 h-10 flex items-center justify-center">
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36">
<path class="stroke-current" fill="none" stroke-width="3"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-accent">
<svg v-if="!isUploading && completeCount === items.length" xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<!-- Status icon -->
<div class="relative w-6 h-6 shrink-0">
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" />
</svg>
<span v-else class="text-[10px] font-bold">{{ progress }}%</span>
<svg v-else class="w-6 h-6 text-slate-400" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold leading-tight truncate">{{ statusText }}</p>
<p class="text-xs text-slate-400 leading-tight mt-0.5">
{{ t('upload.indicator.completeProgress', { complete: completeCount, total: items.length }) }}
</p>
</div>
<!-- Controls -->
<div class="flex items-center gap-1.5 shrink-0">
<!-- Start upload -->
<button v-if="pendingCount > 0 && !isUploading" @click.stop="startQueue"
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-accent hover:bg-accent/80 rounded-lg transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
{{ t('upload.indicator.start') }}
</button>
<button v-else-if="isDoneWithErrors" @click.stop="doneUpload"
class="flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 bg-green-500 hover:bg-green-500/80 text-white rounded-lg transition-all">
{{ t('upload.indicator.viewVideos') }}
</button>
<!-- Clear queue -->
<!-- Add more files -->
<button @click.stop="uiState.uploadDialogVisible = true"
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all"
:title="t('upload.indicator.addMoreFiles')">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12h14" />
<path d="M12 5v14" />
</svg>
</button>
<!-- Collapse/expand -->
<button @click.stop="isCollapsed = !isCollapsed"
class="w-7 h-7 flex items-center justify-center text-slate-400 hover:text-white hover:bg-white/10 rounded-lg transition-all">
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 transition-transform duration-200"
:class="{ 'rotate-180': isCollapsed }" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m18 15-6-6-6 6" />
</svg>
</button>
</div>
</div>
<div class="text-left">
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors">
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }}
</div>
<div class="text-xs text-slate-500">
{{ completeCount }} / {{ items.length }} files
<!-- Overall progress bar -->
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
</div>
</div>
<div v-if="pendingCount"
class="absolute -top-1 -right-1 w-5 h-5 bg-red-500 rounded-full flex items-center justify-center text-[10px] font-bold text-white shadow-sm border-2 border-white">
{{ pendingCount }}
</div>
</button>
</div>
<!-- File list -->
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
<div class="p-3 flex flex-col gap-2">
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
@cancel="cancelItem($event)" />
</div>
</div>
</Transition>
</div>
</Transition>
</template>

View File

@@ -1,116 +1,51 @@
<script setup lang="ts">
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
import { useNotifications } from '@/composables/useNotifications';
import { onClickOutside } from '@vueuse/core';
import { computed, ref, watch } from 'vue';
import { computed, onMounted, 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']);
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 drawerRef = ref(null);
const { t } = useTranslation();
const notificationStore = useNotifications();
// Mock notifications data
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 unreadCount = computed(() => notificationStore.unreadCount.value);
const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
const toggle = (event?: Event) => {
console.log(event);
// Prevent event propagation to avoid immediate closure by onClickOutside
if (event) {
// We don't stop propagation here to let other listeners work,
// but we might need to ignore the trigger element in onClickOutside
// However, since the trigger is outside this component, simple toggle logic works
// if we use a small delay or ignore ref.
// Best approach: "toggle" usually comes from a button click.
}
visible.value = !visible.value;
console.log(visible.value);
if (visible.value && !notificationStore.loaded.value) {
void notificationStore.fetchNotifications();
}
};
// 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.
onClickOutside(drawerRef, () => {
if (visible.value) {
visible.value = false;
}
}, {
ignore: ['[name="Notification"]'] // Assuming the trigger button has this class or we can suggest adding a class to the trigger
ignore: ['[name="Notification"]']
});
const handleMarkRead = (id: string) => {
const notification = notifications.value.find(n => n.id === id);
if (notification) notification.read = true;
const handleMarkRead = async (id: string) => {
await notificationStore.markRead(id);
};
const handleDelete = (id: string) => {
notifications.value = notifications.value.filter(n => n.id !== id);
const handleDelete = async (id: string) => {
await notificationStore.deleteNotification(id);
};
const handleMarkAllRead = () => {
notifications.value.forEach(n => n.read = true);
const handleMarkAllRead = async () => {
await notificationStore.markAllRead();
};
watch(visible, (val) => {
@@ -121,17 +56,16 @@ defineExpose({ toggle });
</script>
<template>
<Teleport to="body">
<Teleport v-if="isMounted" to="body">
<Transition enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
leave-to-class="opacity-0 -translate-x-4">
<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">
<!-- Header -->
<div class="flex items-center justify-between p-4">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900">Notifications</h3>
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
<span v-if="unreadCount > 0"
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
{{ unreadCount }}
@@ -139,49 +73,44 @@ defineExpose({ toggle });
</div>
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
class="text-sm text-primary hover:underline font-medium">
Mark all read
{{ t('notification.actions.markAllRead') }}
</button>
</div>
<!-- Notification List -->
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
<template v-if="notifications.length > 0">
<div v-for="notification in notifications" :key="notification.id"
<template v-if="notificationStore.loading.value">
<div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
<div class="flex items-start gap-4">
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
<div class="flex-1 space-y-2">
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
</div>
</div>
</div>
</template>
<template v-else-if="mutableNotifications.length > 0">
<div v-for="notification in mutableNotifications" :key="notification.id"
class="border-b border-gray-50 last:border-0">
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
@delete="handleDelete" isDrawer />
</div>
</template>
<!-- Empty state -->
<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>
<p class="text-gray-500 text-sm">No notifications</p>
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
</div>
</div>
<!-- Footer -->
<div v-if="notifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
<router-link to="/notification"
class="block w-full text-center text-sm text-primary font-medium hover:underline"
@click="visible = false">
View all notifications
{{ t('notification.actions.viewAll') }}
</router-link>
</div>
</div>
</Transition>
</Teleport>
</template>
<!-- <style>
.notification-popover {
border-radius: 16px !important;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12) !important;
border: 1px solid rgba(0, 0, 0, 0.08) !important;
overflow: hidden;
}
.notification-popover .p-popover-content {
padding: 0 !important;
}
</style> -->

View File

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

View File

@@ -0,0 +1,66 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed } from 'vue';
type Variant = 'primary' | 'secondary' | 'danger' | 'ghost';
type Size = 'sm' | 'md';
const props = withDefaults(defineProps<{
variant?: Variant;
size?: Size;
loading?: boolean;
disabled?: boolean;
type?: 'button' | 'submit' | 'reset';
}>(), {
variant: 'primary',
size: 'md',
loading: false,
disabled: false,
type: 'button',
});
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-all press-animated select-none';
const sizeClass = computed(() => {
switch (props.size) {
case 'sm':
return 'px-3 py-1.5 text-sm';
case 'md':
default:
return 'px-4 py-2 text-sm';
}
});
const variantClass = computed(() => {
switch (props.variant) {
case 'secondary':
return 'bg-muted/50 text-foreground hover:bg-muted border border-border';
case 'danger':
return 'bg-danger text-white hover:bg-danger/90';
case 'ghost':
return 'bg-transparent text-foreground/70 hover:text-foreground hover:bg-muted/50';
case 'primary':
default:
return 'bg-primary text-white hover:bg-primary/90';
}
});
const disabledClass = computed(() => (props.disabled || props.loading) ? 'opacity-60 cursor-not-allowed' : '');
</script>
<template>
<button
:type="type"
:disabled="disabled || loading"
:class="cn(baseClass, sizeClass, variantClass, disabledClass)"
>
<span v-if="loading" class="inline-flex items-center" aria-hidden="true">
<svg class="w-4 h-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8v4a4 4 0 0 0-4 4H4z" />
</svg>
</span>
<slot name="icon" />
<slot />
</button>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppButton from '@/components/app/AppButton.vue';
import AppDialog from '@/components/app/AppDialog.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import { useAppConfirm } from '@/composables/useAppConfirm';
const confirm = useAppConfirm();
</script>
<template>
<AppDialog
:visible="confirm.visible.value"
@update:visible="(v) => !v && confirm.close()"
:title="confirm.header.value"
maxWidthClass="max-w-md"
>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-md bg-warning/10 flex items-center justify-center shrink-0">
<AlertTriangleIcon class="w-5 h-5 text-warning" />
</div>
<p class="text-sm text-foreground/80 leading-relaxed">
{{ confirm.message.value }}
</p>
</div>
<template #footer>
<div class="flex justify-end gap-2">
<AppButton
variant="secondary"
size="sm"
:disabled="confirm.loading.value"
@click="confirm.reject"
>
{{ confirm.rejectLabel.value }}
</AppButton>
<AppButton
variant="danger"
size="sm"
:loading="confirm.loading.value"
@click="confirm.accept"
>
{{ confirm.acceptLabel.value }}
</AppButton>
</div>
</template>
</AppDialog>
</template>

View File

@@ -0,0 +1,113 @@
<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>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed } from 'vue';
// Vue macro is available at compile time; provide a safe fallback for typecheck.
declare const defineModelModifiers: undefined | (<T>() => T);
type Props = {
modelValue?: string | number | null;
type?: string;
placeholder?: string;
readonly?: boolean;
disabled?: boolean;
id?: string;
name?: string;
autocomplete?: string;
inputClass?: string;
wrapperClass?: string;
min?: number | string;
max?: number | string;
step?: number | string;
maxlength?: number;
};
const props = withDefaults(defineProps<Props>(), {
modelValue: '',
type: 'text',
placeholder: '',
readonly: false,
disabled: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: string | number | null): void;
(e: 'enter'): void;
}>();
const modelModifiers = (typeof defineModelModifiers === 'function'
? defineModelModifiers<{ number?: boolean }>()
: ({} as { number?: boolean }));
const isNumberLike = computed(() => props.type === 'number' || !!modelModifiers.number);
const onInput = (e: Event) => {
const el = e.target as HTMLInputElement;
const raw = el.value;
if (isNumberLike.value) {
if (raw === '') {
emit('update:modelValue', null);
return;
}
const n = Number(raw);
emit('update:modelValue', Number.isNaN(n) ? null : n);
return;
}
emit('update:modelValue', raw);
};
const onKeyup = (e: KeyboardEvent) => {
if (e.key === 'Enter') emit('enter');
};
const baseInputClass = 'w-full px-3 py-2 rounded-md border border-border bg-surface text-foreground placeholder:text-foreground/40 focus:outline-none focus:ring-2 focus:ring-primary/30 focus:border-primary/50 disabled:opacity-60 disabled:cursor-not-allowed';
</script>
<template>
<div :class="cn('relative', wrapperClass)">
<div v-if="$slots.prefix" class="absolute left-3 top-1/2 -translate-y-1/2 text-foreground/50">
<slot name="prefix" />
</div>
<input
:id="id"
:name="name"
:type="type"
:value="modelValue ?? ''"
:placeholder="placeholder"
:readonly="readonly"
:disabled="disabled"
:autocomplete="autocomplete"
:min="min"
:max="max"
:step="step"
:maxlength="maxlength"
:class="cn(baseInputClass, $slots.prefix ? 'pl-10' : '', inputClass)"
@input="onInput"
@keyup="onKeyup"
/>
</div>
</template>

View File

@@ -0,0 +1,21 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
import { computed } from 'vue';
const props = defineProps<{
value: number;
class?: string;
}>();
const pct = computed(() => {
const v = Number(props.value);
if (Number.isNaN(v)) return 0;
return Math.min(Math.max(v, 0), 100);
});
</script>
<template>
<div :class="cn('w-full bg-muted/50 rounded-full overflow-hidden', props.class)" style="height: 6px">
<div class="bg-primary h-full rounded-full transition-all duration-300" :style="{ width: `${pct}%` }" />
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
const props = withDefaults(defineProps<{
modelValue: boolean;
disabled?: boolean;
ariaLabel?: string;
}>(), {
disabled: false,
});
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'change', value: boolean): void;
}>();
const toggle = () => {
if (props.disabled) return;
const next = !props.modelValue;
emit('update:modelValue', next);
emit('change', next);
};
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="toggle"
:class="cn(
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
disabled ? 'opacity-60 cursor-not-allowed' : 'cursor-pointer',
modelValue ? 'bg-primary' : 'bg-border'
)"
>
<span
:class="cn(
'inline-block h-5 w-5 transform rounded-full bg-white shadow-sm transition-transform',
modelValue ? 'translate-x-5' : 'translate-x-1'
)"
/>
</button>
</template>

View File

@@ -0,0 +1,101 @@
<script setup lang="ts">
import CheckCircleIcon from '@/components/icons/CheckCircleIcon.vue';
import InfoIcon from '@/components/icons/InfoIcon.vue';
import AlertTriangleIcon from '@/components/icons/AlertTriangleIcon.vue';
import XCircleIcon from '@/components/icons/XCircleIcon.vue';
import XIcon from '@/components/icons/XIcon.vue';
import { cn } from '@/lib/utils';
import { onBeforeUnmount, watchEffect } from 'vue';
import { useAppToast, type AppToastSeverity } from '@/composables/useAppToast';
const { toasts, remove } = useAppToast();
const timers = new Map<string, ReturnType<typeof setTimeout>>();
const dismiss = (id: string) => {
const timer = timers.get(id);
if (timer) {
clearTimeout(timer);
timers.delete(id);
}
remove(id);
};
const iconFor = (severity: AppToastSeverity) => {
switch (severity) {
case 'success':
return CheckCircleIcon;
case 'warn':
return AlertTriangleIcon;
case 'error':
return XCircleIcon;
case 'info':
default:
return InfoIcon;
}
};
const toneClass = (severity: AppToastSeverity) => {
switch (severity) {
case 'success':
return 'border-success/25 bg-success/5';
case 'warn':
return 'border-warning/25 bg-warning/5';
case 'error':
return 'border-danger/25 bg-danger/5';
case 'info':
default:
return 'border-info/25 bg-info/5';
}
};
watchEffect(() => {
if (typeof window === 'undefined') return;
for (const t of toasts.value) {
if (timers.has(t.id)) continue;
const life = Math.max(0, t.life ?? 3000);
const timer = setTimeout(() => {
dismiss(t.id);
}, life);
timers.set(t.id, timer);
}
});
onBeforeUnmount(() => {
for (const timer of timers.values()) clearTimeout(timer);
timers.clear();
});
</script>
<template>
<div class="fixed top-4 right-4 z-[10000] flex flex-col gap-2 w-[360px] max-w-[calc(100vw-2rem)]">
<TransitionGroup
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 translate-y-1"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0 translate-y-1"
>
<div
v-for="t in toasts"
:key="t.id"
:class="cn('flex items-start gap-3 p-3 rounded-lg border shadow-sm', toneClass(t.severity))"
>
<component :is="iconFor(t.severity)" class="w-5 h-5 mt-0.5" :class="t.severity === 'success' ? 'text-success' : t.severity === 'warn' ? 'text-warning' : t.severity === 'error' ? 'text-danger' : 'text-info'" />
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-foreground truncate">{{ t.summary }}</p>
<p v-if="t.detail" class="text-xs text-foreground/70 mt-0.5 break-words">{{ t.detail }}</p>
</div>
<button
type="button"
class="p-1 rounded-md text-foreground/50 hover:text-foreground hover:bg-muted/50 transition-all"
@click="dismiss(t.id)"
:aria-label="$t('toast.dismissAria')"
>
<XIcon class="w-4 h-4" />
</button>
</div>
</TransitionGroup>
</div>
</template>

View File

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

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 3v18h18" />
<path d="m19 9-5 5-4-4-3 3" />
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="#a6acb9"/><path d="M138 124c-71 0-128 57-128 128s57 128 128 128v96c0 18 14 32 32 32h32c18 0 32-14 32-32V124h-96z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="518" viewBox="-10 -244 500 518"><path d="M461-229c12 5 19 16 19 29v416c0 13-7 24-19 29-11 5-25 3-34-5l-47-41c-43-38-98-60-156-63v96c0 18-14 32-32 32h-32c-18 0-32-14-32-32v-96C57 136 0 79 0 8s57-128 128-128h85c61 0 121-23 167-63l47-41c9-8 23-10 34-5zM224 72c70 3 138 29 192 74v-276c-54 45-122 71-192 74V72z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>
</template>

View File

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

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9" />
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
</template>

View File

@@ -1,3 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 532 532"><path d="M10 266c0 37 21 69 51 85-10 33-2 70 24 96s63 34 96 24c16 30 48 51 85 51s69-21 85-51c33 10 70 2 96-24s34-63 24-96c30-16 51-48 51-85s-21-69-51-85c10-33 2-70-24-96s-63-34-96-24c-16-30-48-51-85-51s-69 21-85 51c-33-10-70-2-96 24s-34 63-24 96c-30 16-51 48-51 85zm152 42c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57z" fill="#a6acb9"/><path d="M339 166c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145z" fill="#1e3050"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 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>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/>
<line x1="12" x2="12" y1="15" y2="3"/>
</svg>
</template>

View File

@@ -0,0 +1,5 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512" fill="currentColor">
<path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/>
</svg>
</template>

View File

@@ -0,0 +1,23 @@
<template>
<!-- Local file icon -->
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532">
<path
d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z"
fill="#a6acb9" />
<path
d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z"
fill="#1e3050" />
</svg>
<!-- Remote link icon -->
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 564">
<path
d="M90 258h104c2-1 5-2 8-2 3-117 56-193 99-228C185 42 94 139 90 258zm128-7c28-8 73-22 135-40-5 16-9 31-14 47h103c-3-132-72-209-112-231-39 22-107 96-112 224zm51 247c10 3 21 5 32 6-9-7-18-16-27-26-2 7-3 13-5 20zm11-38c17 22 36 37 50 45 40-22 109-99 112-231H334l-6 21c-16 55-32 110-48 164zm0 0zm79-432c44 35 97 112 99 230h112c-4-119-95-216-211-230zm0 476c116-14 207-111 211-230H458c-2 117-55 195-99 230z"
fill="#a6acb9" />
<path
d="M570 274H458c-2 118-55 195-99 230 116-14 207-111 211-230zM269 498c10 3 21 5 32 6-9-7-18-16-27-26l6-18c18 22 36 37 50 45 40-22 109-99 112-231H335l4-16h103c-3-132-72-209-112-231-39 22-107 96-112 224l-16 5c3-117 56-193 99-228C185 42 94 139 90 258h104l-55 16H90c0 5 1 10 1 14l-16 5c0-9-1-18-1-27C74 125 189 10 330 10s256 115 256 256-115 256-256 256c-23 0-45-3-66-9l5-15zm301-240c-4-119-95-216-211-230 44 35 97 112 99 230h112zM150 414l2 5 46 92 60-205-204 60 91 46 5 2zM31 373l-21-11 23-7 231-68 18-5-5 18-68 232-7 22-60-120-94 94-6 5-11-11 5-6 95-94-100-49z"
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="524" height="524" viewBox="-10 -242 524 524"><path d="M252-232C113-232 0-119 0 20s113 252 252 252S504 159 504 20 391-232 252-232zM37 2c7-92 73-168 161-191-42 55-68 122-71 191H37zm0 36h89c4 69 30 136 71 191-87-23-153-98-160-191zm213 198c-50-52-83-125-87-198h179c-5 73-37 146-88 198h-4zM378 38h89c-7 92-73 168-161 191 42-55 68-122 71-191zm0 0zm0-36c-4-69-30-136-71-191 87 23 153 99 160 191h-89zM254-196c51 53 83 125 87 198H163c4-73 36-145 87-198h4z" fill="currentColor"/></svg>
</template>

View File

@@ -0,0 +1,14 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
<circle cx="12" cy="12" r="10"></circle>
<line x1="2" y1="12" x2="22" y2="12"></line>
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z" />
</svg>
</template>

View File

@@ -1,10 +1,10 @@
<template>
<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"
fill="#a6acb9" />
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<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"
fill="#1e3050" />
fill="var(--colors-primary-DEFAULT)" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
<path

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<line x1="3" x2="21" y1="9" y2="9" />
<line x1="9" x2="9" y1="21" y2="9" />
</svg>
</template>

View File

@@ -1,6 +1,6 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="currentColor"/></svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="14" x="2" y="3" rx="2" />
<line x1="8" x2="16" y1="21" y2="21" />
<line x1="12" x2="12" y1="17" y2="21" />
</svg>
</template>

View File

@@ -0,0 +1,15 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34a.9959.9959 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z"></path>
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="5 3 19 12 5 21 5 3" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="18" x="3" y="3" rx="2" />
<path d="M12 8v8" />
<path d="M8 12h8" />
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 21h5v-5" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="m22 2-7 20-4-9-9-4Z" />
<path d="M22 2 11 13" />
</svg>
</template>

View File

@@ -1,9 +1,5 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L5.09 8.87a.484.484 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.48.48 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.21.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M283 206c46 0 84 37 84 84 0 46-37 84-83 84-47 0-85-37-85-84 0-46 37-84 84-84zm1 196c61 0 111-51 111-112 0-62-51-112-112-112-62 0-112 51-112 112 0 62 51 112 113 112z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path

View File

@@ -0,0 +1,15 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="20" height="16" x="2" y="4" rx="2" />
<circle cx="8" cy="10" r="2" />
<path d="M16 10h.01" />
<path d="M12 10h.01" />
<path d="M2 14h20" />
</svg>
</template>

View File

@@ -0,0 +1,9 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="516" height="516" viewBox="-2 -250 516 516"><path d="M256-240C119-240 8-129 8 8s111 248 248 248S504 145 504 8 393-240 256-240zM371-71c-4 39-20 134-28 178-4 19-10 25-17 25-14 2-25-9-39-18-22-15-34-23-56-37-24-17-8-25 6-40 3-4 67-61 68-67 0 0 0-3-1-4-2-1-4-1-5-1-2 1-37 24-105 70-10 6-19 10-27 9-9 0-26-5-38-9-16-5-28-7-27-16 0-4 7-9 18-14 73-31 121-52 145-62 69-29 83-34 92-34 2 0 7 1 10 3 2 2 3 4 3 7 1 3 1 6 1 10z" fill="currentColor"/></svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" x2="12" y1="3" y2="15" />
</svg>
</template>

View File

@@ -0,0 +1,10 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="#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>

View File

@@ -1,9 +1,9 @@
<template>
<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"
fill="#a6acb9" />
fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
fill="#1e3050" />
fill="var(--colors-primary-DEFAULT)" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
<path

View File

@@ -0,0 +1,9 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M42 170h241c-40 35-65 87-65 144 0 17 2 33 6 48H74c-18 0-32-14-32-32V170z" fill="#a6acb9"/><path d="M458 42H345l-96 96h84c-18 8-35 19-50 32H42v160c0 18 14 32 32 32h150c3 11 7 22 11 32H74c-35 0-64-29-64-64V74c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3V74c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM43 138l96-96H74c-18 0-32 14-32 32v64h1zm46 0h114l96-96H185l-96 96zm321 288c62 0 112-50 112-112s-50-112-112-112-112 50-112 112 50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144 64-144 144-144zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V258c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="564" height="468" viewBox="22 -194 564 468"><path d="M480-152H367l-96 96h84c-18 8-35 19-50 32H64v160c0 18 14 32 32 32h150c3 11 7 22 11 32H96c-35 0-64-29-64-64v-256c0-35 29-64 64-64h384c35 0 64 29 64 64v84c-11-8-23-15-35-20h3v-64c0-5-1-10-3-14l-63 63c-5-1-9-1-14-1-11 0-23 1-33 3l82-83h-1zM65-56l96-96H96c-18 0-32 14-32 32v64h1zm46 0h114l96-96H207l-96 96zm321 288c62 0 112-50 112-112S494 8 432 8 320 58 320 120s50 112 112 112zm0-256c80 0 144 64 144 144s-64 144-144 144-144-64-144-144S352-24 432-24zm-40 74c5-3 11-3 16 0l94 56c4 3 7 8 7 14s-3 11-7 14l-94 56c-5 3-11 3-16 0s-8-8-8-14V64c0-6 3-11 8-14zm24 98 46-28-46-28v56z" fill="#1e3050"/></svg>
</template>
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="4" x2="20" y1="21" y2="21" />
<polygon points="12 11 4 18 4 6 12 11" />
<path d="M16 8.73a2 2 0 0 1 0 3.55" />
<path d="M18 5.05a6 6 0 0 1 0 10.9" />
</svg>
</template>

View File

@@ -0,0 +1,13 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
</template>

View File

@@ -0,0 +1,14 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
<line x1="12" x2="12.01" y1="20" y2="20" />
</svg>
</template>

View File

@@ -0,0 +1,12 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
</template>

View File

@@ -0,0 +1,90 @@
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),
};
};

View File

@@ -0,0 +1,64 @@
import { computed, reactive, readonly } from 'vue';
export type AppToastSeverity = 'success' | 'info' | 'warn' | 'warning' | 'error' | 'danger';
export type AppToastInput = {
severity?: AppToastSeverity;
summary?: string;
detail?: string;
life?: number; // ms
};
export type AppToast = {
id: string;
severity: AppToastSeverity;
summary: string;
detail?: string;
createdAt: number;
life: number;
};
const state = reactive<{ toasts: AppToast[] }>({
toasts: [],
});
const normalizeSeverity = (severity?: AppToastSeverity): AppToastSeverity => {
if (!severity) return 'info';
if (severity === 'warning') return 'warn';
if (severity === 'danger') return 'error';
return severity;
};
const genId = () => `${Date.now()}-${Math.random().toString(16).slice(2)}`;
const add = (input: AppToastInput) => {
const toast: AppToast = {
id: genId(),
severity: normalizeSeverity(input.severity),
summary: input.summary ?? '',
detail: input.detail,
createdAt: Date.now(),
life: typeof input.life === 'number' ? input.life : 3000,
};
state.toasts.push(toast);
return toast.id;
};
const remove = (id: string) => {
const idx = state.toasts.findIndex(t => t.id === id);
if (idx !== -1) state.toasts.splice(idx, 1);
};
const clear = () => {
state.toasts.splice(0, state.toasts.length);
};
export const useAppToast = () => {
return {
add,
remove,
clear,
toasts: computed(() => state.toasts),
_state: readonly(state),
};
};

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { ref, computed } from 'vue';
import { client, ContentType } from '@/api/client';
import { computed, ref } from 'vue';
export interface QueueItem {
id: string;
@@ -12,52 +13,113 @@ export interface QueueItem {
thumbnail?: string;
file?: File; // Keep reference to file for local uploads
url?: string; // Keep reference to url for remote uploads
playbackUrl?: string;
videoId?: string;
mergeId?: string;
// Upload chunk tracking
activeChunks?: number;
uploadedUrls?: string[];
cancelled?: boolean;
}
const items = ref<QueueItem[]>([]);
// Upload limits
const MAX_ITEMS = 5;
// Chunk upload configuration
const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk
const MAX_PARALLEL = 3;
const MAX_RETRY = 3;
// Track active XHRs per item id so we can abort them on cancel
const activeXhrs = new Map<string, Set<XMLHttpRequest>>();
const abortItem = (id: string) => {
const xhrs = activeXhrs.get(id);
if (xhrs) {
xhrs.forEach(xhr => xhr.abort());
activeXhrs.delete(id);
}
};
export function useUploadQueue() {
const t = (key: string, params?: Record<string, unknown>) => key;
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
const addFiles = (files: FileList) => {
const newItems: QueueItem[] = Array.from(files).map((file) => ({
const allowed = Array.from(files).slice(0, remainingSlots.value);
const duplicates: File[] = [];
const fresh: File[] = [];
for (const file of allowed) {
const isDupe = items.value.some(
item => item.type === 'local' && item.name === file.name && item.file?.size === file.size
);
if (isDupe) duplicates.push(file);
else fresh.push(file);
}
const newItems: QueueItem[] = fresh.map((file) => ({
id: Math.random().toString(36).substring(2, 9),
name: file.name,
type: 'local',
status: 'pending', // Start as pending
status: 'pending',
progress: 0,
uploaded: '0 MB',
total: formatSize(file.size),
speed: '0 MB/s',
file: file,
thumbnail: undefined // We could generate a thumbnail here if needed
thumbnail: undefined,
activeChunks: 0,
uploadedUrls: [],
cancelled: false
}));
items.value.push(...newItems);
return { added: newItems.length, skipped: files.length - allowed.length, duplicates: duplicates.length };
};
const addRemoteUrls = (urls: string[]) => {
const newItems: QueueItem[] = urls.map((url) => ({
const allowed = urls.slice(0, remainingSlots.value);
const fresh = allowed.filter(url => !items.value.some(item => item.type === 'remote' && item.url === url));
const duplicateCount = allowed.length - fresh.length;
const newItems: QueueItem[] = fresh.map((url) => ({
id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || 'Remote File',
name: url.split('/').pop() || t('upload.queueItem.remoteFileName'),
type: 'remote',
status: 'fetching', // Remote URLs start fetching immediately or pending? User said "khi nao nhan upload". Let's use pending.
status: 'pending',
progress: 0,
uploaded: '0 MB',
total: 'Unknown',
total: t('upload.queueItem.unknownSize'),
speed: '0 MB/s',
url: url
url: url,
activeChunks: 0,
uploadedUrls: [],
cancelled: false
}));
// Override status to pending for consistency with user request
newItems.forEach(i => i.status = 'pending');
items.value.push(...newItems);
return { added: newItems.length, skipped: urls.length - allowed.length, duplicates: duplicateCount };
};
const removeItem = (id: string) => {
abortItem(id);
const item = items.value.find(i => i.id === id);
if (item) item.cancelled = true;
const index = items.value.findIndex(item => item.id === id);
if (index !== -1) {
items.value.splice(index, 1);
if (index !== -1) items.value.splice(index, 1);
};
const cancelItem = (id: string) => {
abortItem(id);
const item = items.value.find(i => i.id === id);
if (item) {
item.cancelled = true;
item.status = 'error';
item.activeChunks = 0;
item.speed = '0 MB/s';
}
};
@@ -65,7 +127,7 @@ export function useUploadQueue() {
items.value.forEach(item => {
if (item.status === 'pending') {
if (item.type === 'local') {
startMockUpload(item.id);
startChunkUpload(item.id);
} else {
startMockRemoteFetch(item.id);
}
@@ -73,57 +135,214 @@ export function useUploadQueue() {
});
};
// Mock Upload Logic
const startMockUpload = (id: string) => {
// Real Chunk Upload Logic
const startChunkUpload = async (id: string) => {
const item = items.value.find(i => i.id === id);
if (!item) return;
if (!item || !item.file) return;
item.status = 'uploading';
let progress = 0;
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown
item.activeChunks = 0;
item.uploadedUrls = [];
// Random speed between 1MB/s and 5MB/s
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4);
const file = item.file;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i);
const interval = setInterval(() => {
if (progress >= 100) {
clearInterval(interval);
item.status = 'complete';
item.progress = 100;
item.uploaded = item.total;
return;
const updateProgress = () => {
let totalUploaded = 0;
progressMap.forEach(value => {
totalUploaded += value;
});
const percent = Math.min((totalUploaded / file.size) * 100, 100);
item.progress = parseFloat(percent.toFixed(1));
item.uploaded = formatSize(totalUploaded);
// Calculate speed (simplified)
const currentSpeed = item.activeChunks ? item.activeChunks * 2 * 1024 * 1024 : 0;
item.speed = formatSize(currentSpeed) + '/s';
};
const processQueue = async () => {
if (item.cancelled) return;
const activePromises: Promise<void>[] = [];
while ((item.activeChunks || 0) < MAX_PARALLEL && queue.length > 0) {
const index = queue.shift()!;
item.activeChunks = (item.activeChunks || 0) + 1;
const promise = uploadChunk(index, file, progressMap, updateProgress, item)
.then(() => {
item.activeChunks = (item.activeChunks || 0) - 1;
});
activePromises.push(promise);
}
// Increment progress randomly
const increment = Math.random() * 5 + 1; // 1-6% increment
progress = Math.min(progress + increment, 100);
if (activePromises.length > 0) {
await Promise.all(activePromises);
await processQueue();
}
};
item.progress = Math.floor(progress);
try {
await processQueue();
// Calculate uploaded size string
const currentBytes = (progress / 100) * totalSize;
item.uploaded = formatSize(currentBytes);
if (!item.cancelled) {
item.status = 'processing';
await completeUpload(item);
}
} catch (error) {
item.status = 'error';
console.error('Upload failed:', error);
}
};
// Re-randomize speed for realism
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
item.speed = formatSize(currentSpeed) + '/s';
const uploadChunk = (
index: number,
file: File,
progressMap: Map<number, number>,
updateProgress: () => void,
item: QueueItem
): Promise<void> => {
return new Promise((resolve, reject) => {
let retry = 0;
}, 500);
const attempt = () => {
if (item.cancelled) return resolve();
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, file.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
// Register this XHR so it can be aborted on cancel
if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set());
activeXhrs.get(item.id)!.add(xhr);
const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progressMap.set(index, e.loaded);
updateProgress();
}
};
xhr.onload = function () {
unregister();
if (item.cancelled) return resolve();
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.status === 'success') {
progressMap.set(index, chunk.size);
if (item.uploadedUrls) {
item.uploadedUrls[index] = res.data.url;
}
updateProgress();
resolve();
return;
}
} catch {
handleError();
}
}
handleError();
};
xhr.onabort = () => {
unregister();
resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled
};
xhr.onerror = () => {
unregister();
handleError();
};
function handleError() {
retry++;
if (retry <= MAX_RETRY) {
setTimeout(attempt, 2000);
} else {
item.status = 'error';
reject(new Error(t('upload.errors.chunkUploadFailed', { index: index + 1 })));
}
}
xhr.send(formData);
};
attempt();
});
};
const completeUpload = async (item: QueueItem) => {
if (!item.file || !item.uploadedUrls) return;
try {
const response = await fetch('/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: item.file.name,
chunks: item.uploadedUrls,
size: item.file.size
})
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || t('upload.errors.mergeFailed'));
}
const playbackUrl = data.playback_url || data.play_url;
if (!playbackUrl) {
throw new Error('Playback URL missing after merge');
}
const createResponse = await client.videos.videosCreate({
title: item.file.name.replace(/\.[^.]+$/, ''),
description: '',
url: playbackUrl,
size: item.file.size,
duration: 0,
format: item.file.type || 'video/mp4',
}, { baseUrl: '/r' });
const createdVideo = (createResponse.data as any)?.data?.video || (createResponse.data as any)?.data;
item.videoId = createdVideo?.id;
item.mergeId = data.id;
item.playbackUrl = playbackUrl;
item.url = playbackUrl;
item.status = 'complete';
item.progress = 100;
item.uploaded = item.total;
item.speed = '0 MB/s';
} catch (error) {
item.status = 'error';
console.error('Merge failed:', error);
}
};
// Mock Remote Fetch Logic
const startMockRemoteFetch = (id: string) => {
const item = items.value.find(i => i.id === id);
const item = items.value.find(i => i.id === id);
if (!item) return;
item.status = 'fetching'; // Update status to fetching
item.status = 'fetching';
// Remote fetch takes some time then completes
setTimeout(() => {
// Switch to uploading/processing phase if we wanted, or just finish
item.status = 'complete';
item.progress = 100;
}, 3000 + Math.random() * 3000);
setTimeout(() => {
item.status = 'complete';
item.progress = 100;
}, 3000 + Math.random() * 3000);
};
@@ -132,7 +351,8 @@ export function useUploadQueue() {
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${value} ${sizes[i]}`;
};
const totalSize = computed(() => {
@@ -150,15 +370,29 @@ export function useUploadQueue() {
const pendingCount = computed(() => {
return items.value.filter(i => i.status === 'pending').length;
});
function removeAll() {
items.value = [];
}
// watch(items, (newItems) => {
// // console.log(newItems);
// if (newItems.length === 0) return;
// if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) {
// // startQueue();
// items.value = [];
// }
// }, { deep: true });
return {
items,
addFiles,
addRemoteUrls,
removeItem,
cancelItem,
removeAll,
startQueue,
totalSize,
completeCount,
pendingCount
pendingCount,
remainingSlots,
maxItems: MAX_ITEMS,
};
}

View File

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

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

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

View File

@@ -1,111 +1,25 @@
import { renderSSRHead } from '@unhead/vue/server';
import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors";
import { streamText } from 'hono/streaming';
import isMobile from 'is-mobile';
import { renderToWebStream } from 'vue/server-renderer';
import { buildBootstrapScript } from './lib/manifest';
import { styleTags } from './lib/primePassthrough';
import { createApp } from './main';
import { useAuthStore } from './stores/auth';
// @ts-ignore
import Base from '@primevue/core/base';
import { createTextTransformStreamClass } from './lib/replateStreamText';
const app = new Hono()
const defaultNames = ['primitive', 'semantic', 'global', 'base', 'ripple-directive']
// app.use(renderer)
app.use('*', contextStorage());
app.use(cors(), async (c, next) => {
c.set("fetch", app.request.bind(app));
const ua = c.req.header("User-Agent")
if (!ua) {
return c.json({ error: "User-Agent header is missing" }, 400);
};
c.set("isMobile", isMobile({ ua }));
await next();
}, async (c, next) => {
const path = c.req.path
if (path !== '/r' && !path.startsWith('/r/')) {
return await next()
}
const url = new URL(c.req.url)
url.host = 'api.pipic.fun'
url.protocol = 'https:'
url.pathname = path.replace(/^\/r/, '') || '/'
url.port = ''
// console.log("url", url.toString())
// console.log("c.req.raw", c.req.raw)
const headers = new Headers(c.req.header());
headers.delete("host");
headers.delete("connection");
import { apiProxyMiddleware } from './server/middlewares/apiProxy';
import { setupMiddlewares } from './server/middlewares/setup';
import { registerDisplayRoutes } from './server/routes/display';
import { registerManifestRoutes } from './server/routes/manifest';
import { registerMergeRoutes } from './server/routes/merge';
import { registerSSRRoutes } from './server/routes/ssr';
import { registerWellKnownRoutes } from './server/routes/wellKnown';
return fetch(url.toString(), {
method: c.req.method,
headers: headers,
body: c.req.raw.body,
// @ts-ignore
duplex: 'half',
credentials: 'include'
});
});
app.get("/.well-known/*", (c) => {
return c.json({ ok: true });
});
app.get("*", async (c) => {
const nonce = crypto.randomUUID();
const url = new URL(c.req.url);
const { app, router, head, pinia, bodyClass } = createApp();
app.provide("honoContext", c);
const auth = useAuthStore();
auth.$reset();
// auth.initialized = false;
await auth.init();
await router.push(url.pathname);
await router.isReady();
let usedStyles = new Set<String>();
Base.setLoadedStyleName = async (name: string) => usedStyles.add(name)
return streamText(c, async (stream) => {
c.header("Content-Type", "text/html; charset=utf-8");
c.header("Content-Encoding", "Identity");
const ctx: Record<string, any> = {};
const appStream = renderToWebStream(app, ctx);
// console.log("ctx: ", );
await stream.write("<!DOCTYPE html><html lang='en'><head>");
await stream.write("<base href='" + url.origin + "'/>");
const app = new Hono();
await renderSSRHead(head).then((headString) => stream.write(headString.headTags.replace(/\n/g, "")));
// await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"rel="stylesheet"></link>`);
await stream.write(`<link rel="preconnect" href="https://fonts.googleapis.com">`);
await stream.write(`<link href="https://fonts.googleapis.com/css2?family=Google+Sans:ital,opsz,wght@0,17..18,400..700;1,17..18,400..700&display=swap" rel="stylesheet">`);
await stream.write('<link rel="icon" href="/favicon.ico" />');
await stream.write(buildBootstrapScript());
if (usedStyles.size > 0) {
defaultNames.forEach(name => usedStyles.add(name));
}
await Promise.all(styleTags.filter(tag => usedStyles.has(tag.name.replace(/-(variables|style)$/, ""))).map(tag => stream.write(`<style type="text/css" data-primevue-style-id="${tag.name}">${tag.value}</style>`)));
await stream.write(`</head><body class='${bodyClass}'>`);
await stream.pipe(createTextTransformStreamClass(appStream, (text) => text.replace('<div id="anchor-header" class="p-4"></div>', `<div id="anchor-header" class="p-4">${ctx.teleports["#anchor-header"] || ""}</div>`).replace('<div id="anchor-top"></div>', `<div id="anchor-top">${ctx.teleports["#anchor-top"] || ""}</div>`)));
delete ctx.teleports
delete ctx.__teleportBuffers
delete ctx.modules;
Object.assign(ctx, { $p: pinia.state.value });
await stream.write(`<script type="application/json" data-ssr="true" id="__APP_DATA__" nonce="${nonce}">${htmlEscape((JSON.stringify(ctx)))}</script>`);
await stream.write("</body></html>");
});
})
const ESCAPE_LOOKUP: { [match: string]: string } = {
"&": "\\u0026",
">": "\\u003e",
"<": "\\u003c",
"\u2028": "\\u2028",
"\u2029": "\\u2029",
};
// Global middlewares
setupMiddlewares(app);
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
// API proxy middleware (handles /r/*)
app.use(apiProxyMiddleware);
// Routes
registerWellKnownRoutes(app);
registerMergeRoutes(app);
registerDisplayRoutes(app);
registerManifestRoutes(app);
registerSSRRoutes(app);
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}
export default app
export default app;

View File

@@ -0,0 +1,91 @@
import type { DefineStoreOptions, PiniaPluginContext, StateTree } from "pinia";
type Serializer<T extends StateTree> = {
serialize: (value: T) => string;
deserialize: (value: string) => T;
};
interface BroadcastMessage {
type: "STATE_UPDATE" | "SYNC_REQUEST";
timestamp?: number;
state?: string;
}
type PluginOptions<T extends StateTree> = {
enable?: boolean;
initialize?: boolean;
serializer?: Serializer<T>;
};
export interface StoreOptions<
S extends StateTree = StateTree,
G = object,
A = object,
> extends DefineStoreOptions<string, S, G, A> {
share?: PluginOptions<S>;
}
// Add type extension for Pinia
declare module "pinia" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface DefineStoreOptionsBase<S, Store> {
share?: PluginOptions<S>;
}
}
export default function PiniaSharedState<T extends StateTree>({
enable = false,
initialize = false,
serializer = {
serialize: JSON.stringify,
deserialize: JSON.parse,
},
}: PluginOptions<T> = {}) {
return ({ store, options }: PiniaPluginContext) => {
if (!(options.share?.enable ?? enable)) return;
const channel = new BroadcastChannel(store.$id);
let timestamp = 0;
let externalUpdate = false;
// Initial state sync
if (options.share?.initialize ?? initialize) {
channel.postMessage({ type: "SYNC_REQUEST" });
}
// State change listener
store.$subscribe((_mutation, state) => {
if (externalUpdate) return;
timestamp = Date.now();
channel.postMessage({
type: "STATE_UPDATE",
timestamp,
state: serializer.serialize(state as T),
});
});
// Message handler
channel.onmessage = (event: MessageEvent<BroadcastMessage>) => {
const data = event.data;
if (
data.type === "STATE_UPDATE" &&
data.timestamp &&
data.timestamp > timestamp &&
data.state
) {
externalUpdate = true;
timestamp = data.timestamp;
store.$patch(serializer.deserialize(data.state));
externalUpdate = false;
}
if (data.type === "SYNC_REQUEST") {
channel.postMessage({
type: "STATE_UPDATE",
timestamp,
state: serializer.serialize(store.$state as T),
});
}
};
};
}

4
src/lib/interface.ts Normal file
View File

@@ -0,0 +1,4 @@
export interface ITinyMqttClient {
connect(): void;
disconnect(): void;
}

120
src/lib/liteMqtt.ts Normal file
View File

@@ -0,0 +1,120 @@
import { ITinyMqttClient } from "./interface";
export type MessageCallback = (topic: string, payload: string) => void;
export class TinyMqttClient implements ITinyMqttClient {
private ws: WebSocket | null = null;
private encoder = new TextEncoder();
private decoder = new TextDecoder();
private worker: Worker | null = null;
constructor(
private url: string,
private topics: string[],
private onMessage: MessageCallback
) {}
public connect(): void {
this.ws = new WebSocket(this.url, 'mqtt');
this.ws.binaryType = 'arraybuffer';
this.ws.onopen = () => {
this.sendConnect();
};
this.ws.onmessage = (e) => this.handlePacket(new Uint8Array(e.data));
this.ws.onclose = () => this.stopHeartbeatWorker();
}
public disconnect(): void {
this.ws?.close();
this.stopHeartbeatWorker();
}
private sendConnect(): void {
const clientId = `ws_worker_${Math.random().toString(16).slice(2, 8)}`;
const idBytes = this.encoder.encode(clientId);
// Keep-alive 60s
const packet = new Uint8Array([
0x10, 12 + idBytes.length,
0x00, 0x04, 0x4d, 0x51, 0x54, 0x54, 0x04, 0x02, 0x00, 0x3c,
0x00, idBytes.length, ...idBytes
]);
this.ws?.send(packet);
}
private startHeartbeatWorker(): void {
if (this.worker) return;
// Tạo nội dung Worker dưới dạng chuỗi
const workerCode = `
let timer = null;
self.onmessage = (e) => {
if (e.data === 'START') {
timer = setInterval(() => self.postMessage('TICK'), 30000);
} else if (e.data === 'STOP') {
clearInterval(timer);
}
};
`;
const blob = new Blob([workerCode], { type: 'application/javascript' });
this.worker = new Worker(URL.createObjectURL(blob));
this.worker.onmessage = (e) => {
if (e.data === 'TICK' && this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(new Uint8Array([0xC0, 0x00])); // Gửi PINGREQ
}
};
this.worker.postMessage('START');
}
private stopHeartbeatWorker(): void {
if (this.worker) {
this.worker.postMessage('STOP');
this.worker.terminate();
this.worker = null;
console.log('🛑 Worker stopped');
}
}
private handlePacket(data: Uint8Array): void {
const type = data[0] & 0xF0;
switch (type) {
case 0x20: // CONNACK
this.startHeartbeatWorker();
this.subscribe();
break;
case 0xD0: // PINGRESP
break;
case 0x30: // PUBLISH
this.parsePublish(data);
break;
}
}
private subscribe(): void {
let payload: number[] = [];
this.topics.forEach(t => {
const b = this.encoder.encode(t);
payload.push(0x00, b.length, ...Array.from(b), 0x00);
});
const packet = new Uint8Array([0x82, 2 + payload.length, 0x00, 0x01, ...payload]);
this.ws?.send(packet);
}
private parsePublish(data: Uint8Array): void {
const tLen = (data[2] << 8) | data[3];
const topic = this.decoder.decode(data.slice(4, 4 + tLen));
const payload = this.decoder.decode(data.slice(4 + tLen));
this.onMessage(topic, payload);
}
}
// --- Cách dùng ---
// const client = new TinyMqttClient(
// 'ws://your-emqx:8083',
// ['sensor/temp', 'sensor/humi', 'system/ping'],
// (topic, msg) => {
// console.log(`[${topic}]: ${msg}`);
// }
// );
// client.connect();

File diff suppressed because one or more lines are too long

View File

@@ -1,77 +0,0 @@
import SWRVCache, { type ICacheItem } from '..'
import type { IKey } from '../../types'
/**
* LocalStorage cache adapter for swrv data cache.
* https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
*/
export default class LocalStorageCache extends SWRVCache<any> {
private STORAGE_KEY
constructor (key = 'swrv', ttl = 0) {
super(ttl)
this.STORAGE_KEY = key
}
private encode (storage: any) { return JSON.stringify(storage) }
private decode (storage: any) { return JSON.parse(storage) }
get (k: IKey): ICacheItem<IKey> {
const item = localStorage.getItem(this.STORAGE_KEY)
if (item) {
const _key = this.serializeKey(k)
const itemParsed: ICacheItem<any> = JSON.parse(item)[_key]
if (itemParsed?.expiresAt === null) {
itemParsed.expiresAt = Infinity // localStorage sets Infinity to 'null'
}
return itemParsed
}
return undefined as any
}
set (k: string, v: any, ttl: number) {
let payload = {}
const _key = this.serializeKey(k)
const timeToLive = ttl || this.ttl
const storage = localStorage.getItem(this.STORAGE_KEY)
const now = Date.now()
const item = {
data: v,
createdAt: now,
expiresAt: timeToLive ? now + timeToLive : Infinity
}
if (storage) {
payload = this.decode(storage)
(payload as any)[_key] = item
} else {
payload = { [_key]: item }
}
this.dispatchExpire(timeToLive, item, _key)
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
}
dispatchExpire (ttl: number, item: any, serializedKey: string) {
ttl && setTimeout(() => {
const current = Date.now()
const hasExpired = current >= item.expiresAt
if (hasExpired) this.delete(serializedKey)
}, ttl)
}
delete (serializedKey: string) {
const storage = localStorage.getItem(this.STORAGE_KEY)
let payload = {} as Record<string, any>
if (storage) {
payload = this.decode(storage)
delete payload[serializedKey]
}
localStorage.setItem(this.STORAGE_KEY, this.encode(payload))
}
}

View File

@@ -1,72 +0,0 @@
import type { IKey } from '../types'
import hash from '../lib/hash'
export interface ICacheItem<Data> {
data: Data,
createdAt: number,
expiresAt: number
}
function serializeKeyDefault (key: IKey): string {
if (typeof key === 'function') {
try {
key = key()
} catch (err) {
// dependencies not ready
key = ''
}
}
if (Array.isArray(key)) {
key = hash(key)
} else {
// convert null to ''
key = String(key || '')
}
return key
}
export default class SWRVCache<CacheData> {
protected ttl: number
private items?: Map<string, ICacheItem<CacheData>>
constructor (ttl = 0) {
this.items = new Map()
this.ttl = ttl
}
serializeKey (key: IKey): string {
return serializeKeyDefault(key)
}
get (k: string): ICacheItem<CacheData> {
const _key = this.serializeKey(k)
return this.items!.get(_key)!
}
set (k: string, v: any, ttl: number) {
const _key = this.serializeKey(k)
const timeToLive = ttl || this.ttl
const now = Date.now()
const item = {
data: v,
createdAt: now,
expiresAt: timeToLive ? now + timeToLive : Infinity
}
this.dispatchExpire(timeToLive, item, _key)
this.items!.set(_key, item)
}
dispatchExpire (ttl: number, item: any, serializedKey: string) {
ttl && setTimeout(() => {
const current = Date.now()
const hasExpired = current >= item.expiresAt
if (hasExpired) this.delete(serializedKey)
}, ttl)
}
delete (serializedKey: string) {
this.items!.delete(serializedKey)
}
}

View File

@@ -1,8 +0,0 @@
import SWRVCache from './cache'
import useSWRV, { mutate } from './use-swrv'
export {
type IConfig
} from './types'
export { mutate, SWRVCache }
export default useSWRV

View File

@@ -1,44 +0,0 @@
// From https://github.com/vercel/swr/blob/master/src/libs/hash.ts
// use WeakMap to store the object->key mapping
// so the objects can be garbage collected.
// WeakMap uses a hashtable under the hood, so the lookup
// complexity is almost O(1).
const table = new WeakMap()
// counter of the key
let counter = 0
// hashes an array of objects and returns a string
export default function hash (args: any[]): string {
if (!args.length) return ''
let key = 'arg'
for (let i = 0; i < args.length; ++i) {
let _hash
if (
args[i] === null ||
(typeof args[i] !== 'object' && typeof args[i] !== 'function')
) {
// need to consider the case that args[i] is a string:
// args[i] _hash
// "undefined" -> '"undefined"'
// undefined -> 'undefined'
// 123 -> '123'
// null -> 'null'
// "null" -> '"null"'
if (typeof args[i] === 'string') {
_hash = '"' + args[i] + '"'
} else {
_hash = String(args[i])
}
} else {
if (!table.has(args[i])) {
_hash = counter
table.set(args[i], counter++)
} else {
_hash = table.get(args[i])
}
}
key += '@' + _hash
}
return key
}

View File

@@ -1,27 +0,0 @@
function isOnline (): boolean {
if (typeof navigator.onLine !== 'undefined') {
return navigator.onLine
}
// always assume it's online
return true
}
function isDocumentVisible (): boolean {
if (
typeof document !== 'undefined' &&
typeof document.visibilityState !== 'undefined'
) {
return document.visibilityState !== 'hidden'
}
// always assume it's visible
return true
}
const fetcher = (url: string | Request) => fetch(url).then(res => res.json())
export default {
isOnline,
isDocumentVisible,
fetcher
}

View File

@@ -1,42 +0,0 @@
import type { Ref, WatchSource } from 'vue'
import SWRVCache from './cache'
import LocalStorageCache from './cache/adapters/localStorage'
export type fetcherFn<Data> = (...args: any) => Data | Promise<Data>
export interface IConfig<
Data = any,
Fn extends fetcherFn<Data> = fetcherFn<Data>
> {
refreshInterval?: number
cache?: LocalStorageCache | SWRVCache<any>
dedupingInterval?: number
ttl?: number
serverTTL?: number
revalidateOnFocus?: boolean
revalidateDebounce?: number
shouldRetryOnError?: boolean
errorRetryInterval?: number
errorRetryCount?: number
fetcher?: Fn,
isOnline?: () => boolean
isDocumentVisible?: () => boolean
}
export interface revalidateOptions {
shouldRetryOnError?: boolean,
errorRetryCount?: number,
forceRevalidate?: boolean,
}
export interface IResponse<Data = any, Error = any> {
data: Ref<Data | undefined>
error: Ref<Error | undefined>
isValidating: Ref<boolean>
isLoading: Ref<boolean>
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => Promise<void>
}
export type keyType = string | any[] | null | undefined
export type IKey = keyType | WatchSource<keyType>

View File

@@ -1,470 +0,0 @@
/** ____
*--------------/ \.------------------/
* / swrv \. / //
* / / /\. / //
* / _____/ / \. /
* / / ____/ . \. /
* / \ \_____ \. /
* / . \_____ \ \ / //
* \ _____/ / ./ / //
* \ / _____/ ./ /
* \ / / . ./ /
* \ / / ./ /
* . \/ ./ / //
* \ ./ / //
* \.. / /
* . ||| /
* ||| /
* . ||| / //
* ||| / //
* ||| /
*/
import { tinyassert } from "@hiogawa/utils";
import {
getCurrentInstance,
inject,
isReadonly,
isRef,
// isRef,
onMounted,
onServerPrefetch,
onUnmounted,
reactive,
ref,
toRefs,
useSSRContext,
watch,
type FunctionPlugin
} from 'vue';
import SWRVCache from './cache';
import webPreset from './lib/web-preset';
import type { IConfig, IKey, IResponse, fetcherFn, revalidateOptions } from './types';
type StateRef<Data, Error> = {
data: Data, error: Error, isValidating: boolean, isLoading: boolean, revalidate: Function, key: any
};
const DATA_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
const REF_CACHE = new SWRVCache<StateRef<any, any>[]>()
const PROMISES_CACHE = new SWRVCache<Omit<IResponse, 'mutate'>>()
const defaultConfig: IConfig = {
cache: DATA_CACHE,
refreshInterval: 0,
ttl: 0,
serverTTL: 1000,
dedupingInterval: 2000,
revalidateOnFocus: true,
revalidateDebounce: 0,
shouldRetryOnError: true,
errorRetryInterval: 5000,
errorRetryCount: 5,
fetcher: webPreset.fetcher,
isOnline: webPreset.isOnline,
isDocumentVisible: webPreset.isDocumentVisible
}
/**
* Cache the refs for later revalidation
*/
function setRefCache(key: string, theRef: StateRef<any, any>, ttl: number) {
const refCacheItem = REF_CACHE.get(key)
if (refCacheItem) {
refCacheItem.data.push(theRef)
} else {
// #51 ensures ref cache does not evict too soon
const gracePeriod = 5000
REF_CACHE.set(key, [theRef], ttl > 0 ? ttl + gracePeriod : ttl)
}
}
function onErrorRetry(revalidate: (any: any, opts: revalidateOptions) => void, errorRetryCount: number, config: IConfig): void {
if (!(config as any).isDocumentVisible()) {
return
}
if (config.errorRetryCount !== undefined && errorRetryCount > config.errorRetryCount) {
return
}
const count = Math.min(errorRetryCount || 0, (config as any).errorRetryCount)
const timeout = count * (config as any).errorRetryInterval
setTimeout(() => {
revalidate(null, { errorRetryCount: count + 1, shouldRetryOnError: true })
}, timeout)
}
/**
* Main mutation function for receiving data from promises to change state and
* set data cache
*/
const mutate = async <Data>(key: string, res: Promise<Data> | Data, cache = DATA_CACHE, ttl = defaultConfig.ttl) => {
let data, error, isValidating
if (isPromise(res)) {
try {
data = await res
} catch (err) {
error = err
}
} else {
data = res
}
// eslint-disable-next-line prefer-const
isValidating = false
const newData = { data, error, isValidating }
if (typeof data !== 'undefined') {
try {
cache.set(key, newData, Number(ttl))
} catch (err) {
console.error('swrv(mutate): failed to set cache', err)
}
}
/**
* Revalidate all swrv instances with new data
*/
const stateRef = REF_CACHE.get(key)
if (stateRef && stateRef.data.length) {
// This filter fixes #24 race conditions to only update ref data of current
// key, while data cache will continue to be updated if revalidation is
// fired
let refs = stateRef.data.filter(r => r.key === key)
refs.forEach((r, idx) => {
if (typeof newData.data !== 'undefined') {
r.data = newData.data
}
r.error = newData.error
r.isValidating = newData.isValidating
r.isLoading = newData.isValidating
const isLast = idx === refs.length - 1
if (!isLast) {
// Clean up refs that belonged to old keys
delete refs[idx]
}
})
refs = refs.filter(Boolean)
}
return newData
}
/* Stale-While-Revalidate hook to handle fetching, caching, validation, and more... */
function useSWRV<Data = any, Error = any>(
key: IKey
): IResponse<Data, Error>
function useSWRV<Data = any, Error = any>(
key: IKey,
fn: fetcherFn<Data> | undefined | null,
config?: IConfig
): IResponse<Data, Error>
function useSWRV<Data = any, Error = any>(...args: any[]): IResponse<Data, Error> {
const injectedConfig = inject<Partial<IConfig> | null>('swrv-config', null)
tinyassert(injectedConfig, 'Injected swrv-config must be an object')
let key: IKey
let fn: fetcherFn<Data> | undefined | null
let config: IConfig = { ...defaultConfig, ...injectedConfig }
let unmounted = false
let isHydrated = false
const instance = getCurrentInstance() as any
const vm = instance?.proxy || instance // https://github.com/vuejs/composition-api/pull/520
if (!vm) {
console.error('Could not get current instance, check to make sure that `useSwrv` is declared in the top level of the setup function.')
throw new Error('Could not get current instance')
}
const IS_SERVER = typeof window === 'undefined' || false
// #region ssr
const isSsrHydration = Boolean(
!IS_SERVER &&
window !== undefined && (window as any).window.swrv)
// #endregion
if (args.length >= 1) {
key = args[0]
}
if (args.length >= 2) {
fn = args[1]
}
if (args.length > 2) {
config = {
...config,
...args[2]
}
}
const ttl = IS_SERVER ? config.serverTTL : config.ttl
const keyRef = typeof key === 'function' ? (key as any) : ref(key)
if (typeof fn === 'undefined') {
// use the global fetcher
fn = config.fetcher
}
let stateRef: StateRef<Data, Error> | null = null
// #region ssr
if (isSsrHydration) {
// component was ssrHydrated, so make the ssr reactive as the initial data
const swrvState = (window as any).window.swrv || []
const swrvKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
if (swrvKey !== undefined && swrvKey !== null) {
const nodeState = swrvState[swrvKey] || []
const instanceState = nodeState[nanoHex(isRef(keyRef) ? keyRef.value : keyRef())]
if (instanceState) {
stateRef = reactive(instanceState)
isHydrated = true
}
}
}
// #endregion
if (!stateRef) {
stateRef = reactive({
data: undefined,
error: undefined,
isValidating: true,
isLoading: true,
key: null
}) as StateRef<Data, Error>
}
/**
* Revalidate the cache, mutate data
*/
const revalidate = async (data?: fetcherFn<Data>, opts?: revalidateOptions) => {
const isFirstFetch = stateRef.data === undefined
const keyVal = keyRef.value
if (!keyVal) { return }
const cacheItem = config.cache!.get(keyVal)
const newData = cacheItem && cacheItem.data
stateRef.isValidating = true
stateRef.isLoading = !newData
if (newData) {
stateRef.data = newData.data
stateRef.error = newData.error
}
const fetcher = data || fn
if (
!fetcher ||
(!IS_SERVER && !(config as any).isDocumentVisible() && !isFirstFetch) ||
(opts?.forceRevalidate !== undefined && !opts?.forceRevalidate)
) {
stateRef.isValidating = false
stateRef.isLoading = false
return
}
// Dedupe items that were created in the last interval #76
if (cacheItem) {
const shouldRevalidate = Boolean(
((Date.now() - cacheItem.createdAt) >= (config as any).dedupingInterval) || opts?.forceRevalidate
)
if (!shouldRevalidate) {
stateRef.isValidating = false
stateRef.isLoading = false
return
}
}
const trigger = async () => {
const promiseFromCache = PROMISES_CACHE.get(keyVal)
if (!promiseFromCache) {
const fetcherArgs = Array.isArray(keyVal) ? keyVal : [keyVal]
const newPromise = fetcher(...fetcherArgs)
PROMISES_CACHE.set(keyVal, newPromise, (config as any).dedupingInterval)
await mutate(keyVal, newPromise, (config as any).cache, ttl)
} else {
await mutate(keyVal, promiseFromCache.data, (config as any).cache, ttl)
}
stateRef.isValidating = false
stateRef.isLoading = false
PROMISES_CACHE.delete(keyVal)
if (stateRef.error !== undefined) {
const shouldRetryOnError = !unmounted && config.shouldRetryOnError && (opts ? opts.shouldRetryOnError : true)
if (shouldRetryOnError) {
onErrorRetry(revalidate, opts ? Number(opts.errorRetryCount) : 1, config)
}
}
}
if (newData && config.revalidateDebounce) {
setTimeout(async () => {
if (!unmounted) {
await trigger()
}
}, config.revalidateDebounce)
} else {
await trigger()
}
}
const revalidateCall = async () => revalidate(null as any, { shouldRetryOnError: false })
let timer: any = null
/**
* Setup polling
*/
onMounted(() => {
const tick = async () => {
// component might un-mount during revalidate, so do not set a new timeout
// if this is the case, but continue to revalidate since promises can't
// be cancelled and new hook instances might rely on promise/data cache or
// from pre-fetch
if (!stateRef.error && (config as any).isOnline()) {
// if API request errored, we stop polling in this round
// and let the error retry function handle it
await revalidate()
} else {
if (timer) {
clearTimeout(timer)
}
}
if (config.refreshInterval && !unmounted) {
timer = setTimeout(tick, config.refreshInterval)
}
}
if (config.refreshInterval) {
timer = setTimeout(tick, config.refreshInterval)
}
if (config.revalidateOnFocus) {
document.addEventListener('visibilitychange', revalidateCall, false)
window.addEventListener('focus', revalidateCall, false)
}
})
/**
* Teardown
*/
onUnmounted(() => {
unmounted = true
if (timer) {
clearTimeout(timer)
}
if (config.revalidateOnFocus) {
document.removeEventListener('visibilitychange', revalidateCall, false)
window.removeEventListener('focus', revalidateCall, false)
}
const refCacheItem = REF_CACHE.get(keyRef.value)
if (refCacheItem) {
refCacheItem.data = refCacheItem.data.filter((ref) => ref !== stateRef)
}
})
// #region ssr
if (IS_SERVER) {
const ssrContext = useSSRContext()
// make sure srwv exists in ssrContext
let swrvRes: Record<string, any> = {}
if (ssrContext) {
swrvRes = ssrContext.swrv = ssrContext.swrv || swrvRes
}
const ssrKey = nanoHex(vm.$.type.__name ?? vm.$.type.name)
// if (!vm.$vnode || (vm.$node && !vm.$node.data)) {
// vm.$vnode = {
// data: { attrs: { 'data-swrv-key': ssrKey } }
// }
// }
// const attrs = (vm.$vnode.data.attrs = vm.$vnode.data.attrs || {})
// attrs['data-swrv-key'] = ssrKey
// // Nuxt compatibility
// if (vm.$ssrContext && vm.$ssrContext.nuxt) {
// vm.$ssrContext.nuxt.swrv = swrvRes
// }
if (ssrContext) {
ssrContext.swrv = swrvRes
}
onServerPrefetch(async () => {
await revalidate()
if (!swrvRes[ssrKey]) swrvRes[ssrKey] = {}
swrvRes[ssrKey][nanoHex(keyRef.value)] = {
data: stateRef.data,
error: stateRef.error,
isValidating: stateRef.isValidating
}
})
}
// #endregion
/**
* Revalidate when key dependencies change
*/
try {
watch(keyRef, (val) => {
if (!isReadonly(keyRef)) {
keyRef.value = val
}
stateRef.key = val
stateRef.isValidating = Boolean(val)
setRefCache(keyRef.value, stateRef, Number(ttl))
if (!IS_SERVER && !isHydrated && keyRef.value) {
revalidate()
}
isHydrated = false
}, {
immediate: true
})
} catch {
// do nothing
}
const res: IResponse = {
...toRefs(stateRef),
mutate: (data?: fetcherFn<Data>, opts?: revalidateOptions) => revalidate(data, {
...opts,
forceRevalidate: true
})
}
return res
}
function isPromise<T>(p: any): p is Promise<T> {
return p !== null && typeof p === 'object' && typeof p.then === 'function'
}
/**
* string to hex 8 chars
* @param name string
* @returns string
*/
function nanoHex(name: string): string {
try {
let hash = 0
for (let i = 0; i < name.length; i++) {
const chr = name.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0 // Convert to 32bit integer
}
let hex = (hash >>> 0).toString(16)
while (hex.length < 8) {
hex = '0' + hex
}
return hex
} catch {
console.error("err name: ", name)
return '0000'
}
}
export const vueSWR = (swrvConfig: Partial<IConfig> = defaultConfig): FunctionPlugin => (app) => {
app.config.globalProperties.$swrv = useSWRV
// app.provide('swrv', useSWRV)
app.provide('swrv-config', swrvConfig)
}
export { mutate };
export default useSWRV

View File

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

View File

@@ -49,44 +49,51 @@ export function getImageAspectRatio(url: string): Promise<AspectInfo> {
});
}
export const formatBytes = (bytes?: number) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const value = parseFloat((bytes / Math.pow(k, i)).toFixed(2));
return `${value} ${sizes[i]}`;
// return `${new Intl.NumberFormat(getRuntimeLocaleTag()).format(value)} ${sizes[i]}`;
};
export const formatDuration = (seconds?: number) => {
if (!seconds) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (!seconds) return '0:00';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
if (h > 0) {
return `${h}:${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
}
return `${m}:${s.toString().padStart(2, '0')}`;
};
export const formatDate = (dateString?: string) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
export const formatDate = (dateString: string = "", dateOnly: boolean = false) => {
if (!dateString) return '';
const locale = typeof document !== 'undefined'
? document.documentElement.lang === 'vi' ? 'vi-VN' : 'en-US'
: 'en-US';
return new Date(dateString).toLocaleDateString(locale, {
month: 'short',
day: 'numeric',
year: 'numeric',
...(dateOnly ? {} : { hour: '2-digit', minute: '2-digit' })
});
};
export const getStatusClass = (status?: string) => {
switch (status?.toLowerCase()) {
case 'ready': return 'bg-green-100 text-green-700';
case 'processing': return 'bg-yellow-100 text-yellow-700';
case 'failed': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
export const getStatusSeverity = (status: string = "") => {
switch (status) {
case 'success':
case 'ready':
return 'success';
case 'failed':
return 'danger';
case 'pending':
return 'warn';
default:
return 'info';
}
};

View File

@@ -1,56 +1,65 @@
import { createHead as CSRHead } from "@unhead/vue/client";
import { createHead as SSRHead } from "@unhead/vue/server";
import { PiniaColada, useQueryCache } from '@pinia/colada';
import { createHead as CSRHead } from '@unhead/vue/client';
import { createHead as SSRHead } from '@unhead/vue/server';
import { createPinia } from 'pinia';
import { createSSRApp } from 'vue';
import { RouterView } from 'vue-router';
import I18NextVue from 'i18next-vue';
import { withErrorBoundary } from './lib/hoc/withErrorBoundary';
import { vueSWR } from './lib/swr/use-swrv';
import createI18nInstance from './lib/translation';
import createAppRouter from './routes';
import PrimeVue from 'primevue/config';
import Aura from '@primeuix/themes/aura';
import { createPinia } from "pinia";
import { useAuthStore } from './stores/auth';
import ToastService from 'primevue/toastservice';
import Tooltip from 'primevue/tooltip';
const bodyClass = ":uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen"
export function createApp() {
const bodyClass = ':uno: font-sans text-gray-800 antialiased flex flex-col min-h-screen';
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 app = createSSRApp(withErrorBoundary(RouterView));
const head = import.meta.env.SSR ? SSRHead() : CSRHead();
const appData = !import.meta.env.SSR ? getSerializedAppData() : ({} as Record<string, any>);
app.use(head);
app.use(PrimeVue, {
// unstyled: true,
theme: {
preset: Aura,
options: {
darkModeSelector: '.my-app-dark',
cssLayer: false,
// cssLayer: {
// name: 'primevue',
// order: 'theme, base, primevue'
// }
}
}
});
app.use(ToastService);
app.directive('nh', {
created(el) {
el.__v_skip = true;
}
});
app.directive("tooltip", Tooltip)
app.use(pinia);
app.use(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) {
Object.entries(JSON.parse(document.getElementById("__APP_DATA__")?.innerText || "{}")).forEach(([key, value]) => {
Object.entries(appData).forEach(([key, value]) => {
(window as any)[key] = value;
});
if ((window as any).$p) {
pinia.state.value = (window as any).$p;
}
}
app.use(pinia);
app.use(vueSWR({ revalidateOnFocus: false }));
const router = createAppRouter();
app.use(router);
return { app, router, head, pinia, bodyClass };
return { app, router, head, pinia, bodyClass, queryCache };
}

View File

@@ -319,3 +319,39 @@ export const fetchMockVideos = async ({ page, limit, searchQuery, status }: Fetc
total
};
};
export const fetchMockVideoById = async (id: string) => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 500));
const video = mockVideos.find(v => v.id === id);
if (!video) {
throw new Error('Video not found');
}
return video;
};
export const updateMockVideo = async (id: string, updates: { title: string; description?: string }) => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 800));
const videoIndex = mockVideos.findIndex(v => v.id === id);
if (videoIndex === -1) {
throw new Error('Video not found');
}
mockVideos[videoIndex] = {
...mockVideos[videoIndex],
title: updates.title,
description: updates.description,
updated_at: new Date().toISOString()
};
return mockVideos[videoIndex];
};
export const deleteMockVideo = async (id: string) => {
// Simulate API delay
await new Promise(resolve => setTimeout(resolve, 600));
const videoIndex = mockVideos.findIndex(v => v.id === id);
if (videoIndex === -1) {
throw new Error('Video not found');
}
mockVideos.splice(videoIndex, 1);
return { success: true };
};

View File

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

View File

@@ -1,397 +0,0 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue';
import { useRouter } from 'vue-router';
import PageHeader from '@/components/dashboard/PageHeader.vue';
import StatsCard from '@/components/dashboard/StatsCard.vue';
import { client, type ModelVideo } from '@/api/client';
import Skeleton from 'primevue/skeleton';
const router = useRouter();
const loading = ref(true);
const recentVideos = ref<ModelVideo[]>([]);
// Mock stats data (in real app, fetch from API)
const stats = ref({
totalVideos: 0,
totalViews: 0,
storageUsed: 0,
storageLimit: 10737418240, // 10GB in bytes
uploadsThisMonth: 0
});
const quickActions = [
{
title: 'Upload Video',
description: 'Upload a new video to your library',
icon: 'i-heroicons-cloud-arrow-up',
color: 'bg-gradient-to-br from-primary/20 to-primary/5',
iconColor: 'text-primary',
onClick: () => router.push('/upload')
},
{
title: 'Video Library',
description: 'Browse all your videos',
icon: 'i-heroicons-film',
color: 'bg-gradient-to-br from-blue-100 to-blue-50',
iconColor: 'text-blue-600',
onClick: () => router.push('/video')
},
{
title: 'Analytics',
description: 'Track performance & insights',
icon: 'i-heroicons-chart-bar',
color: 'bg-gradient-to-br from-purple-100 to-purple-50',
iconColor: 'text-purple-600',
onClick: () => {}
},
{
title: 'Manage Plan',
description: 'Upgrade or change your plan',
icon: 'i-heroicons-credit-card',
color: 'bg-gradient-to-br from-orange-100 to-orange-50',
iconColor: 'text-orange-600',
onClick: () => router.push('/plans')
},
];
const fetchDashboardData = async () => {
loading.value = true;
try {
// Fetch recent videos
const response = await client.videos.videosList({ page: 1, limit: 5 });
const body = response.data as any;
if (body.data && Array.isArray(body.data)) {
recentVideos.value = body.data;
stats.value.totalVideos = body.data.length;
} else if (Array.isArray(body)) {
recentVideos.value = body;
stats.value.totalVideos = body.length;
}
// Calculate mock stats
stats.value.totalViews = recentVideos.value.reduce((sum, v: any) => sum + (v.views || 0), 0);
stats.value.storageUsed = recentVideos.value.reduce((sum, v) => sum + (v.size || 0), 0);
stats.value.uploadsThisMonth = recentVideos.value.filter(v => {
const uploadDate = new Date(v.created_at || '');
const now = new Date();
return uploadDate.getMonth() === now.getMonth() && uploadDate.getFullYear() === now.getFullYear();
}).length;
} catch (err) {
console.error('Failed to fetch dashboard data:', err);
} finally {
loading.value = false;
}
};
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const formatDuration = (seconds?: number) => {
if (!seconds) return '0:00';
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
const formatDate = (dateString?: string) => {
if (!dateString) return '';
return new Date(dateString).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
};
const getStatusClass = (status?: string) => {
switch(status?.toLowerCase()) {
case 'ready': return 'bg-green-100 text-green-700';
case 'processing': return 'bg-yellow-100 text-yellow-700';
case 'failed': return 'bg-red-100 text-red-700';
default: return 'bg-gray-100 text-gray-700';
}
};
const storagePercentage = computed(() => {
return Math.round((stats.value.storageUsed / stats.value.storageLimit) * 100);
});
const storageBreakdown = computed(() => {
const videoSize = stats.value.storageUsed;
const thumbSize = stats.value.totalVideos * 300 * 1024; // ~300KB per thumbnail
const otherSize = stats.value.totalVideos * 100 * 1024; // ~100KB other files
const total = videoSize + thumbSize + otherSize;
return [
{ label: 'Videos', size: videoSize, percentage: (videoSize / total) * 100, color: 'bg-primary' },
{ label: 'Thumbnails & Assets', size: thumbSize, percentage: (thumbSize / total) * 100, color: 'bg-blue-500' },
{ label: 'Other Files', size: otherSize, percentage: (otherSize / total) * 100, color: 'bg-gray-400' },
];
});
onMounted(() => {
fetchDashboardData();
});
</script>
<template>
<div class="dashboard-overview">
<PageHeader
title="Dashboard"
description="Welcome back! Here's what's happening with your videos."
:breadcrumbs="[
{ label: 'Dashboard' }
]"
/>
<!-- Loading State -->
<div v-if="loading" class="animate-pulse">
<!-- Stats Grid Skeleton -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<div v-for="i in 4" :key="i" class="bg-white rounded-xl border border-gray-200 p-6">
<div class="flex items-center justify-between mb-4">
<div class="space-y-2">
<Skeleton width="5rem" height="1rem" class="mb-2"></Skeleton>
<Skeleton width="8rem" height="2rem"></Skeleton>
</div>
<Skeleton shape="circle" size="3rem"></Skeleton>
</div>
<Skeleton width="4rem" height="1rem"></Skeleton>
</div>
</div>
<!-- Quick Actions Skeleton -->
<div class="mb-8">
<Skeleton width="10rem" height="1.5rem" class="mb-4"></Skeleton>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div v-for="i in 4" :key="i" class="p-6 rounded-xl border border-gray-200">
<Skeleton shape="circle" size="3rem" class="mb-4"></Skeleton>
<Skeleton width="8rem" height="1.25rem" class="mb-2"></Skeleton>
<Skeleton width="100%" height="1rem"></Skeleton>
</div>
</div>
</div>
<!-- Recent Videos Skeleton -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<Skeleton width="8rem" height="1.5rem"></Skeleton>
<Skeleton width="5rem" height="1rem"></Skeleton>
</div>
<div class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="p-4 border-b border-gray-200" v-for="i in 5" :key="i">
<div class="flex gap-4">
<Skeleton width="4rem" height="2.5rem" class="rounded"></Skeleton>
<div class="flex-1 space-y-2">
<Skeleton width="30%" height="1rem"></Skeleton>
<Skeleton width="20%" height="0.8rem"></Skeleton>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
<StatsCard
title="Total Videos"
:value="stats.totalVideos"
icon="i-heroicons-film"
color="primary"
:trend="{ value: 12, isPositive: true }"
/>
<StatsCard
title="Total Views"
:value="stats.totalViews.toLocaleString()"
icon="i-heroicons-eye"
color="info"
:trend="{ value: 8, isPositive: true }"
/>
<StatsCard
title="Storage Used"
:value="`${formatBytes(stats.storageUsed)} / ${formatBytes(stats.storageLimit)}`"
icon="i-heroicons-server"
color="warning"
/>
<StatsCard
title="Uploads This Month"
:value="stats.uploadsThisMonth"
icon="i-heroicons-arrow-up-tray"
color="success"
:trend="{ value: 25, isPositive: true }"
/>
</div>
<!-- Quick Actions -->
<div class="mb-8">
<h2 class="text-xl font-semibold mb-4">Quick Actions</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<button
v-for="action in quickActions"
:key="action.title"
@click="action.onClick"
:class="[
'p-6 rounded-xl text-left transition-all duration-200',
'border border-gray-200 hover:border-primary hover:shadow-lg',
'group press-animated',
action.color
]"
>
<div :class="['w-12 h-12 rounded-lg flex items-center justify-center mb-4 bg-white/80', action.iconColor]">
<span :class="[action.icon, 'w-6 h-6']" />
</div>
<h3 class="font-semibold mb-1 group-hover:text-primary transition-colors">{{ action.title }}</h3>
<p class="text-sm text-gray-600">{{ action.description }}</p>
</button>
</div>
</div>
<!-- Recent Videos -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold">Recent Videos</h2>
<router-link
to="/video"
class="text-sm text-primary hover:underline font-medium flex items-center gap-1"
>
View all
<span class="i-heroicons-arrow-right w-4 h-4" />
</router-link>
</div>
<div v-if="recentVideos.length === 0" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-4">
<span class="i-heroicons-film w-8 h-8 text-gray-400" />
</div>
<p class="text-gray-600 mb-4">No videos yet</p>
<router-link
to="/upload"
class="inline-flex items-center gap-2 px-4 py-2 bg-primary hover:bg-primary-600 text-white rounded-lg font-medium transition-colors"
>
<span class="i-heroicons-plus w-5 h-5" />
Upload your first video
</router-link>
</div>
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-200">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Video</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Duration</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Upload Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
<tr v-for="video in recentVideos" :key="video.id" class="hover:bg-gray-50 transition-colors">
<td class="px-6 py-4">
<div class="flex items-center gap-3">
<div class="w-16 h-10 bg-gray-200 rounded overflow-hidden flex-shrink-0">
<img v-if="video.thumbnail" :src="video.thumbnail" :alt="video.title" class="w-full h-full object-cover" />
<div v-else class="w-full h-full flex items-center justify-center">
<span class="i-heroicons-film text-gray-400 text-xl" />
</div>
</div>
<div class="min-w-0">
<p class="font-medium text-gray-900 truncate">{{ video.title }}</p>
<p class="text-sm text-gray-500 truncate">{{ video.description || 'No description' }}</p>
</div>
</div>
</td>
<td class="px-6 py-4">
<span :class="['px-2 py-1 text-xs font-medium rounded-full', getStatusClass(video.status)]">
{{ video.status || 'Unknown' }}
</span>
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDuration(video.duration) }}
</td>
<td class="px-6 py-4 text-sm text-gray-500">
{{ formatDate(video.created_at) }}
</td>
<td class="px-6 py-4">
<div class="flex items-center gap-2">
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Edit">
<span class="i-heroicons-pencil w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-gray-100 rounded transition-colors" title="Share">
<span class="i-heroicons-share w-4 h-4 text-gray-600" />
</button>
<button class="p-1.5 hover:bg-red-100 rounded transition-colors" title="Delete">
<span class="i-heroicons-trash w-4 h-4 text-red-600" />
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<!-- Storage Usage -->
<div class="bg-white rounded-xl border border-gray-200 p-6">
<h2 class="text-xl font-semibold mb-4">Storage Usage</h2>
<div class="mb-4">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">
{{ formatBytes(stats.storageUsed) }} of {{ formatBytes(stats.storageLimit) }} used
</span>
<span class="text-sm font-medium" :class="storagePercentage > 80 ? 'text-danger' : 'text-gray-700'">
{{ storagePercentage }}%
</span>
</div>
<div class="h-3 bg-gray-200 rounded-full overflow-hidden">
<div
class="h-full transition-all duration-500 rounded-full"
:class="storagePercentage > 80 ? 'bg-danger' : 'bg-primary'"
:style="{ width: `${storagePercentage}%` }"
/>
</div>
</div>
<div class="space-y-2">
<div
v-for="item in storageBreakdown"
:key="item.label"
class="flex items-center justify-between text-sm"
>
<div class="flex items-center gap-2">
<div :class="['w-3 h-3 rounded-sm', item.color]" />
<span class="text-gray-700">{{ item.label }}</span>
</div>
<span class="text-gray-500">{{ formatBytes(item.size) }}</span>
</div>
</div>
<div v-if="storagePercentage > 80" class="mt-4 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<div class="flex gap-2">
<span class="i-heroicons-exclamation-triangle w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div>
<p class="text-sm font-medium text-yellow-800">Storage running low</p>
<p class="text-sm text-yellow-700 mt-1">
Consider upgrading your plan to get more storage.
<router-link to="/plans" class="underline font-medium">View plans</router-link>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,20 +1,17 @@
<template>
<div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2">
Enter your email address and we'll send you a link to reset your password.
{{ t('auth.forgot.description') }}
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.forgot.email') }}</label>
<AppInput id="email" v-model="form.email" type="email" :placeholder="t('auth.forgot.placeholders.email')" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<Button type="submit" size="small" label="Send Reset Link" fluid />
<AppButton type="submit" class="w-full">{{ t('auth.forgot.sendResetLink') }}</AppButton>
<div class="text-center mt-2">
<router-link to="/login" replace
@@ -23,51 +20,61 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Back to Sign in
{{ t('auth.forgot.backToSignIn') }}
</router-link>
</div>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import Toast from 'primevue/toast';
import { client } from '@/api/client';
import { useAppToast } from '@/composables/useAppToast';
import { reactive } from 'vue';
import { useTranslation } from 'i18next-vue';
import { z } from 'zod';
import { client } from '@/api/client';
import { useAuthStore } from '@/stores/auth';
import { useToast } from "primevue/usetoast";
const toast = useAppToast();
const { t } = useTranslation();
const auth = useAuthStore();
const toast = useToast();
const initialValues = reactive({
const form = reactive({
email: ''
});
const resolver = zodResolver(
z.object({
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
})
);
const errors = reactive<{ email?: string }>({});
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
client.auth.forgotPasswordCreate({ email: values.email })
.then(() => {
toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
})
.catch((error) => {
toast.add({ severity: 'error', summary: 'Error', detail: error.message || 'An error occurred', life: 3000 });
});
// forgotPassword(values.email).then(() => {
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// }).catch(() => {
// toast.add({ severity: 'error', summary: 'Error', detail: auth.error, life: 3000 });
// });
const schema = z.object({
email: z.string().min(1, { message: t('auth.forgot.errors.emailRequired') }).email({ message: t('auth.forgot.errors.emailInvalid') })
});
const onFormSubmit = () => {
errors.email = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
client.auth.forgotPasswordCreate({ email: form.email })
.then(() => {
toast.add({
severity: 'success',
summary: t('auth.forgot.toast.successSummary'),
detail: t('auth.forgot.toast.successDetail'),
life: 3000,
});
})
.catch((error) => {
toast.add({
severity: 'error',
summary: t('auth.forgot.toast.errorSummary'),
detail: error.message || t('auth.forgot.toast.errorDetail'),
life: 3000,
});
});
};
</script>

View File

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

View File

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

View File

@@ -1,99 +1,120 @@
<template>
<div class="w-full">
<Toast />
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email</label>
<InputText size="small" name="email" type="text" placeholder="Enter your email" fluid
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.login.email') }}</label>
<AppInput id="email" v-model="form.email" type="text" :placeholder="t('auth.signup.placeholders.email')"
:disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" size="small" placeholder="Enter your password" :feedback="false" toggleMask
fluid :inputStyle="{ width: '100%' }" :disabled="auth.loading" />
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error?.message }}</Message>
<label for="password" class="text-sm font-medium text-gray-700">{{ t('auth.login.password') }}</label>
<div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.signup.placeholders.password')" :disabled="auth.loading" />
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
<svg v-if="!showPassword" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Checkbox inputId="remember-me" size="small" name="rememberMe" binary :disabled="auth.loading" />
<label for="remember-me" class="text-sm text-gray-900">Remember me</label>
</div>
<div class="flex items-center justify-end">
<div class="text-sm">
<router-link to="/forgot"
class="text-blue-600 hover:text-blue-500 hover:underline">Forgot
password?</router-link>
class="text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.forgotPassword') }}</router-link>
</div>
</div>
<Button type="submit" size="small" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid
:loading="auth.loading" />
<AppButton type="submit" :loading="auth.loading" class="w-full">
{{ auth.loading ? `${t('common.loading')}...` : t('auth.login.signIn') }}
</AppButton>
<div class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
<span class="px-2 bg-white text-gray-500">{{ t('auth.login.google') }}</span>
</div>
</div>
<Button size="small" type="button" variant="outlined" severity="secondary"
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
<AppButton type="button" variant="secondary" class="w-full flex items-center justify-center gap-2"
@click="loginWithGoogle" :disabled="auth.loading">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
Google
</Button>
{{ t('auth.login.google') }}
</AppButton>
<div class="mt-2 flex flex-col items-center justify-center gap-1 text-sm text-gray-600">
<p class="text-center text-sm text-gray-600">
Don't have an account?
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up</router-link>
{{ t('auth.login.noAccount') }}
<router-link to="/sign-up"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.login.signUp') }}</router-link>
</p>
<!-- <router-link to="/forgot" class="text-blue-600 hover:text-blue-500 hover:underline">Forgot password?</router-link> -->
</div>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from '@/stores/auth';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import Toast from 'primevue/toast';
import { useToast } from "primevue/usetoast";
import { reactive } from 'vue';
import { useAppToast } from '@/composables/useAppToast';
import { reactive, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { z } from 'zod';
const t = useToast();
const auth = useAuthStore();
// const $form = Form.useFormContext();
watch(() => auth.error, (newError) => {
if (newError) {
t.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const initialValues = reactive({
const toast = useAppToast();
const auth = useAuthStore();
const showPassword = ref(false);
const { t } = useTranslation();
const form = reactive({
email: '',
password: '',
rememberMe: false
});
const resolver = zodResolver(
z.object({
email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
})
);
const errors = reactive<{ email?: string; password?: string }>({});
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
if (valid) auth.login(values.email, values.password);
const schema = z.object({
email: z.string().min(1, { message: t('auth.login.errors.emailRequired') }),
password: z.string().min(1, { message: t('auth.login.errors.passwordRequired') })
});
watch(() => auth.error, (newError) => {
if (newError) {
toast.add({ severity: 'error', summary: String(auth.error), detail: newError, life: 5000 });
}
});
const onFormSubmit = () => {
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
auth.login(form.email, form.password);
};
const loginWithGoogle = () => {

View File

@@ -1,70 +1,91 @@
<template>
<div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<form @submit.prevent="onFormSubmit" class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
<InputText size="small" name="name" placeholder="John Doe" fluid />
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
$form.name.error?.message }}</Message>
<label for="name" class="text-sm font-medium text-gray-700">{{ t('auth.signup.fullName') }}</label>
<AppInput id="name" v-model="form.name" :placeholder="t('auth.signup.placeholders.name')" />
<p v-if="errors.name" class="text-xs text-red-500 mt-0.5">{{ errors.name }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText size="small" name="email" type="email" placeholder="you@example.com" fluid />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
<label for="email" class="text-sm font-medium text-gray-700">{{ t('auth.signup.email') }}</label>
<AppInput id="email" v-model="form.email" type="email" :placeholder="t('auth.signup.placeholders.email')" />
<p v-if="errors.email" class="text-xs text-red-500 mt-0.5">{{ errors.email }}</p>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" size="small" placeholder="Create a password" :feedback="true" toggleMask fluid
:inputStyle="{ width: '100%' }" />
<small class="text-gray-500">Must be at least 8 characters.</small>
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error?.message }}</Message>
<label for="password" class="text-sm font-medium text-gray-700">{{ t('auth.signup.password') }}</label>
<div class="relative">
<AppInput id="password" v-model="form.password" :type="showPassword ? 'text' : 'password'"
:placeholder="t('auth.signup.placeholders.password')" />
<button type="button"
class="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600"
@click="showPassword = !showPassword" tabindex="-1">
<svg v-if="!showPassword" class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
<svg v-else class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
</button>
</div>
<small class="text-gray-500">{{ t('auth.signup.passwordHint') }}</small>
<p v-if="errors.password" class="text-xs text-red-500 mt-0.5">{{ errors.password }}</p>
</div>
<Button type="submit" size="small" label="Create Account" fluid />
<AppButton type="submit" class="w-full">{{ t('auth.signup.createAccount') }}</AppButton>
<p class="mt-4 text-center text-sm text-gray-600">
Already have an account?
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
in</router-link>
{{ t('auth.signup.alreadyHave') }}
<router-link to="/login"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">{{ t('auth.signup.signIn') }}</router-link>
</p>
</Form>
</form>
</div>
</template>
<script setup lang="ts">
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { reactive } from 'vue';
import { useAuthStore } from '@/stores/auth';
import { reactive, ref } from 'vue';
import { useTranslation } from 'i18next-vue';
import { z } from 'zod';
import { useAuthStore } from '@/stores/auth';
const auth = useAuthStore();
const showPassword = ref(false);
const { t } = useTranslation();
const initialValues = reactive({
const form = reactive({
name: '',
email: '',
password: ''
});
const resolver = zodResolver(
z.object({
name: z.string().min(1, { message: 'Name is required.' }),
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
})
);
const errors = reactive<{ name?: string; email?: string; password?: string }>({});
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
auth.register(values.name, values.email, values.password);
const schema = z.object({
name: z.string().min(1, { message: t('auth.signup.errors.nameRequired') }),
email: z.string().min(1, { message: t('auth.signup.errors.emailRequired') }).email({ message: t('auth.signup.errors.emailInvalid') }),
password: z.string().min(8, { message: t('auth.signup.errors.passwordMin') })
});
const onFormSubmit = () => {
errors.name = undefined;
errors.email = undefined;
errors.password = undefined;
const result = schema.safeParse(form);
if (!result.success) {
for (const issue of result.error.issues) {
const field = issue.path[0] as keyof typeof errors;
if (field in errors) errors[field] = issue.message;
}
return;
}
auth.register(form.name, form.email, form.password);
};
</script>

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