39 Commits

Author SHA1 Message Date
b60f65e4d1 feat: add admin components for input, metrics, tables, and user forms
- Introduced AdminInput component for standardized input fields.
- Created AdminMetricCard for displaying metrics with customizable tones.
- Added AdminPlaceholderTable for loading states in tables.
- Developed AdminSectionCard for consistent section layouts.
- Implemented AdminSectionShell for organizing admin sections.
- Added AdminSelect for dropdown selections with v-model support.
- Created AdminTable for displaying tabular data with loading and empty states.
- Introduced AdminTextarea for multi-line text input.
- Developed AdminUserFormFields for user creation and editing forms.
- Added useAdminPageHeader composable for managing admin page header state.
2026-03-24 07:08:44 +00:00
e854c68ad0 fix: clean up imports and remove shadow effect from loading bar 2026-03-19 09:53:06 +07:00
b787cd161a Refactor admin routes and implement S3 manifest handling
- Updated video detail modal to use new ad template property naming convention.
- Refactored RPC routes to include admin methods for user, video, payment, plan, and ad template management.
- Introduced S3 helper functions for manifest creation, saving, fetching, and validation of chunk URLs.
- Added new admin methods for managing jobs and agents.
- Created a new UserIcon component for better icon management.
- Enhanced validation functions to support multiple schemas.
2026-03-19 01:43:49 +07:00
bd8b21955e feat: add BaseTable component for improved table rendering
- Introduced a new BaseTable component to enhance table functionality with sorting and loading states.
- Updated upload queue logic to support chunk uploads and improved error handling.
- Refactored various admin routes to utilize the new BaseTable component.
- Adjusted import paths for UI components to maintain consistency.
- Enhanced upload handling with better progress tracking and cancellation support.
- Updated theme colors in uno.config.ts for a more cohesive design.
2026-03-18 22:23:11 +07:00
87c99e64cd feat: add AsyncSelect component and update related types and headers handling 2026-03-17 22:49:58 +07:00
baa8811e9e add jobid to video 2026-03-17 13:15:18 +00:00
fa88fe26b3 feat: refactor billing plans section and remove unused components
- Updated BillingPlansSection.vue to clean up unused code and improve readability.
- Removed CardPopover.vue and VideoGrid.vue components as they were no longer needed.
- Enhanced VideoTable.vue by integrating BaseTable for better table management and added loading states.
- Introduced secure JSON transformer for enhanced data security in RPC routes.
- Added key resolver for managing server key pairs.
- Created a script to generate NaCl keys for secure communications.
- Implemented admin page header management for better UI consistency.
2026-03-17 18:54:14 +07:00
90d8409aa9 refactor: update UI styles to use new header background color
- Changed background color for various select elements and containers in Users.vue and Videos.vue to use 'bg-header'.
- Updated background color for status and role filters in the admin section.
- Adjusted background colors in Home.vue, QuickActions.vue, and other components to enhance UI consistency.
- Refactored Billing.vue and DomainsDns.vue to align with new design standards.
- Modified settings components to utilize new header color for better visual hierarchy.
- Improved accessibility and visual feedback in the SettingsRow and SettingsSectionCard components.
- Updated authentication middleware to include timestamp cookie for session management.
- Enhanced gRPC client to build internal metadata for service calls.
2026-03-16 17:09:31 +07:00
b4bbacd9f1 add getuserbyid method 2026-03-16 08:31:02 +00:00
8b85736903 fix: use Bun RedisClient type in server setup
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 04:50:37 +00:00
3beabcfe7f update migrate 2026-03-12 15:17:31 +00:00
35117b7be9 feat: update settings and package configurations to include estree-walker and enhance script permissions 2026-03-12 21:56:56 +07:00
e3587eff71 feat: enhance gRPC authentication flow and improve token management 2026-03-12 17:56:55 +07:00
57903b80b6 update grpc 2026-03-12 09:33:28 +00:00
5c0ca0e139 feat: refactor authentication and user management routes
- Removed the API proxy middleware and integrated RPC routes for user authentication.
- Implemented JWT token generation and validation in the authentication middleware.
- Enhanced user registration and login processes with password hashing and token management.
- Added new routes for user password reset and Google OAuth login.
- Introduced health check endpoints for service monitoring.
- Updated gRPC client methods for user management, including password updates.
- Refactored utility functions for token handling and Redis interactions.
- Improved type definitions for better TypeScript support.
2026-03-11 23:57:14 +07:00
9276603a70 feat: implement JWT token provider with access and refresh token generation 2026-03-11 19:01:23 +07:00
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
272 changed files with 53731 additions and 7579 deletions

View File

@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(bun run build)",
"mcp__ide__getDiagnostics",
"Bash(bun install:*)",
"Bash(bun preview:*)",
"Bash(curl:*)",
"Bash(python -:*)",
"Bash(bun run:*)",
"Bash(bunx:*)",
"Bash(bun:*)",
"Bash(git diff:*)",
"Bash(git add:*)",
"Bash(git status:*)"
]
}
}

68
.dockerignore Normal file
View File

@@ -0,0 +1,68 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# Build outputs
dist
build
.rsbuild
node_modules
/node_modules
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE and editor files
.vscode
.idea
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose.yml
# Documentation
README.md
*.md
# Test files
coverage
.coverage
.nyc_output
test
tests
__tests__
*.test.js
*.test.ts
*.spec.js
*.spec.ts
# Linting
.eslintrc*
.prettierrc*
.stylelintrc*
# Other
.husky

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.

39
Dockerfile Normal file
View File

@@ -0,0 +1,39 @@
# ---------- Builder stage ----------
FROM oven/bun:1.3.10-alpine AS builder
WORKDIR /app
# Copy lockfiles & package.json
COPY package*.json ./
COPY bun.lockb* ./
COPY yarn.lock* ./
COPY pnpm-lock.yaml* ./
# Install dependencies (cached)
RUN --mount=type=cache,target=/root/.bun bun install
# Copy source
COPY . .
# Build app (RSBuild output -> dist)
RUN bun run build
# ---------- Production stage ----------
FROM oven/bun:1.3.10-alpine AS production
WORKDIR /app
# Copy built files
COPY --from=builder /app/dist ./dist
ENV NODE_ENV=production
# Expose port
EXPOSE 3000
# Optional health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:3000/ || exit 1
# Run Bun with fallback install (auto resolves missing deps)
CMD [ "bun", "--bun", "dist" ]

19
buf.gen.yaml Normal file
View File

@@ -0,0 +1,19 @@
version: v2
plugins:
# - remote: buf.build/protocolbuffers/go
# out: internal/gen/proto
# opt:
# - paths=source_relative
# - remote: buf.build/grpc/go
# out: internal/gen/proto
# opt:
# - paths=source_relative
- remote: buf.build/community/stephenh-ts-proto
out: ./src/server/utils/proto
opt:
- env=node
- esModuleInterop=true
- outputServices=grpc-js
- useOptionals=all
- forceLong=number
- useDate=string

9
buf.yaml Normal file
View File

@@ -0,0 +1,9 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
breaking:
use:
- FILE

1086
bun.lock

File diff suppressed because it is too large Load Diff

110
components.d.ts vendored
View File

@@ -12,103 +12,169 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
Add: typeof import('./src/components/icons/Add.vue')['default'] Add: typeof import('./src/components/icons/Add.vue')['default']
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default']
Bell: typeof import('./src/components/icons/Bell.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'] Chart: typeof import('./src/components/icons/Chart.vue')['default']
Checkbox: typeof import('primevue/checkbox')['default']
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default'] CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default'] CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default'] ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
Credit: typeof import('./src/components/icons/Credit.vue')['default'] Credit: typeof import('./src/components/icons/Credit.vue')['default']
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
FloatLabel: typeof import('primevue/floatlabel')['default'] FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default'] GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
Globe: typeof import('./src/components/icons/Globe.vue')['default']
GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default'] HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
Home: typeof import('./src/components/icons/Home.vue')['default'] Home: typeof import('./src/components/icons/Home.vue')['default']
IconField: typeof import('primevue/iconfield')['default'] ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default'] InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
InputIcon: typeof import('primevue/inputicon')['default']
InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/components/icons/Layout.vue')['default'] Layout: typeof import('./src/components/icons/Layout.vue')['default']
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
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'] NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
PageHeader: typeof import('./src/components/dashboard/PageHeader.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'] 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'] RootLayout: typeof import('./src/components/RootLayout.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
Select: typeof import('primevue/select')['default'] SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default'] SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
Skeleton: typeof import('primevue/skeleton')['default'] SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default'] StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default'] TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
Upload: typeof import('./src/components/icons/Upload.vue')['default'] Upload: typeof import('./src/components/icons/Upload.vue')['default']
UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
Video: typeof import('./src/components/icons/Video.vue')['default'] Video: typeof import('./src/components/icons/Video.vue')['default']
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default'] VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
VueHead: typeof import('./src/components/VueHead.tsx')['default'] VueHead: typeof import('./src/components/VueHead.tsx')['default']
WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
} }
} }
// For TSX support // For TSX support
declare global { declare global {
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
const Add: typeof import('./src/components/icons/Add.vue')['default'] const Add: typeof import('./src/components/icons/Add.vue')['default']
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default'] const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
const AppButton: typeof import('./src/components/ui/AppButton.vue')['default']
const AppConfirmHost: typeof import('./src/components/ui/AppConfirmHost.vue')['default']
const AppDialog: typeof import('./src/components/ui/AppDialog.vue')['default']
const AppInput: typeof import('./src/components/ui/AppInput.vue')['default']
const AppProgressBar: typeof import('./src/components/ui/AppProgressBar.vue')['default']
const AppSwitch: typeof import('./src/components/ui/AppSwitch.vue')['default']
const AppToastHost: typeof import('./src/components/ui/AppToastHost.vue')['default']
const AppTopLoadingBar: typeof import('./src/components/AppTopLoadingBar.vue')['default']
const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default'] const ArrowDownTray: typeof import('./src/components/icons/ArrowDownTray.vue')['default']
const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default'] const ArrowRightIcon: typeof import('./src/components/icons/ArrowRightIcon.vue')['default']
const AsyncSelect: typeof import('./src/components/ui/AsyncSelect.vue')['default']
const BaseTable: typeof import('./src/components/ui/BaseTable.vue')['default']
const Bell: typeof import('./src/components/icons/Bell.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 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 CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default'] const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default'] const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default'] const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
const copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
const Credit: typeof import('./src/components/icons/Credit.vue')['default'] const Credit: typeof import('./src/components/icons/Credit.vue')['default']
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default'] const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default'] const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default'] const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default'] const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
const 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 GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
const Globe: typeof import('./src/components/icons/Globe.vue')['default']
const GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default'] const HardDriveUpload: typeof import('./src/components/icons/HardDriveUpload.vue')['default']
const HeartIcon: typeof import('./src/components/icons/HeartIcon.vue')['default']
const Home: typeof import('./src/components/icons/Home.vue')['default'] const Home: typeof import('./src/components/icons/Home.vue')['default']
const 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 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 Layout: typeof import('./src/components/icons/Layout.vue')['default']
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default'] const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
const 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 NotificationDrawer: typeof import('./src/components/NotificationDrawer.vue')['default']
const OfflineOverlay: typeof import('./src/components/OfflineOverlay.vue')['default']
const PageHeader: typeof import('./src/components/dashboard/PageHeader.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 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 RootLayout: typeof import('./src/components/RootLayout.vue')['default']
const RouterLink: typeof import('vue-router')['RouterLink'] const RouterLink: typeof import('vue-router')['RouterLink']
const RouterView: typeof import('vue-router')['RouterView'] const RouterView: typeof import('vue-router')['RouterView']
const 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 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 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 TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default'] const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
const Upload: typeof import('./src/components/icons/Upload.vue')['default'] const Upload: typeof import('./src/components/icons/Upload.vue')['default']
const UploadIcon: typeof import('./src/components/icons/UploadIcon.vue')['default']
const UserIcon: typeof import('./src/components/icons/UserIcon.vue')['default']
const 'UserIcon copy': typeof import('./src/components/icons/UserIcon copy.vue')['default']
const Video: typeof import('./src/components/icons/Video.vue')['default'] const Video: typeof import('./src/components/icons/Video.vue')['default']
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default'] const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
const VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
const VueHead: typeof import('./src/components/VueHead.tsx')['default'] const VueHead: typeof import('./src/components/VueHead.tsx')['default']
const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default'] const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
} }

3223
docs.json

File diff suppressed because it is too large Load Diff

View File

@@ -2,45 +2,46 @@
"name": "holistream", "name": "holistream",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "bunx --bun vite",
"build": "vite build", "build": "bunx --bun vite build",
"preview": "vite preview", "preview": "bunx --bun vite preview"
"deploy": "wrangler deploy",
"cf-typegen": "wrangler types --env-interface CloudflareBindings",
"tail": "wrangler tail"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.971.0", "@bufbuild/protobuf": "^2.11.0",
"@aws-sdk/s3-presigned-post": "^3.971.0", "@grpc/grpc-js": "^1.14.3",
"@aws-sdk/s3-request-presigner": "^3.971.0", "@hattip/adapter-node": "^0.0.49",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18", "@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.0", "@hiogawa/utils": "^1.7.0",
"@primeuix/themes": "^2.0.3", "@hono/node-server": "^1.19.11",
"@primevue/forms": "^4.5.4", "@hono/zod-validator": "^0.7.6",
"@unhead/vue": "^2.1.2", "@pinia/colada": "^1.0.0",
"@vueuse/core": "^14.1.0", "@tanstack/vue-table": "^8.21.3",
"@unhead/vue": "^2.1.12",
"@vueuse/core": "^14.2.1",
"aws4fetch": "^1.0.20",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"firebase-admin": "^13.6.0", "hono": "^4.12.7",
"hono": "^4.11.4", "i18next": "^25.8.18",
"i18next-http-backend": "^3.0.2",
"i18next-vue": "^5.4.0",
"is-mobile": "^5.0.0", "is-mobile": "^5.0.0",
"pinia": "^3.0.4", "pinia": "^3.0.4",
"primevue": "^4.5.4", "superjson": "^2.2.6",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.5.0",
"vue": "^3.5.27", "tweetnacl": "^1.0.3",
"vue-router": "^4.6.4", "vue": "^3.5.30",
"zod": "^4.3.5" "vue-router": "^5.0.3",
"zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.21.0", "@types/bun": "^1.3.10",
"@primevue/auto-import-resolver": "^4.5.4", "@vitejs/plugin-vue": "^6.0.4",
"@types/node": "^25.0.9", "@vitejs/plugin-vue-jsx": "^5.1.4",
"@vitejs/plugin-vue": "^6.0.3", "estree-walker": "2.0.2",
"@vitejs/plugin-vue-jsx": "^5.1.3", "unocss": "^66.6.6",
"unocss": "^66.6.0",
"unplugin-auto-import": "^21.0.0", "unplugin-auto-import": "^21.0.0",
"unplugin-vue-components": "^31.0.0", "unplugin-vue-components": "^31.0.0",
"vite": "^7.3.1", "vite": "^8.0.0-beta.16",
"vite-ssr-components": "^0.5.2", "vite-ssr-components": "^0.5.2"
"wrangler": "^4.59.2"
} }
} }

46
proto/v1/common.proto Normal file
View File

@@ -0,0 +1,46 @@
syntax = "proto3";
package stream.common.v1;
option go_package = "stream/proto/gen/go/common/v1;commonv1";
import "google/protobuf/timestamp.proto";
message RequestContext {
string user_id = 1;
string email = 2;
string role = 3;
string request_id = 4;
string source = 5;
}
message PaginationRequest {
int32 page = 1;
int32 page_size = 2;
}
message PaginationResponse {
int32 page = 1;
int32 page_size = 2;
int64 total = 3;
}
message Money {
double amount = 1;
string currency = 2;
}
message Empty {}
message IdRequest {
string id = 1;
}
message DeleteResponse {
string message = 1;
}
message TimestampRange {
google.protobuf.Timestamp from = 1;
google.protobuf.Timestamp to = 2;
}

134
proto/v1/user.proto Normal file
View File

@@ -0,0 +1,134 @@
syntax = "proto3";
package stream.User.v1;
option go_package = "stream/proto/gen/go/User/v1;Userv1";
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
service UserService {
// User CRUD
rpc GetUser(GetUserRequest) returns (GetUserResponse);
rpc GetUserByEmail(GetUserByEmailRequest) returns (GetUserResponse);
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse);
rpc CreateUser(CreateUserRequest) returns (CreateUserResponse);
rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse);
rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse);
rpc UpdateUserPassword(UpdateUserPasswordRequest) returns (google.protobuf.Empty);
// Preferences
rpc GetPreferences(GetPreferencesRequest) returns (GetPreferencesResponse);
rpc UpsertPreferences(UpsertPreferencesRequest) returns (UpsertPreferencesResponse);
}
// ─── User Messages ───────────────────────────────────────────────────────────
message UpdateUserPasswordRequest {
string id = 1;
string new_password = 2;
}
message GetUserRequest {
string id = 1;
}
message GetUserByEmailRequest {
string email = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 page = 1;
int32 page_size = 2;
string role = 3; // optional filter
}
message ListUsersResponse {
repeated User users = 1;
int32 total = 2;
int32 page = 3;
int32 page_size = 4;
}
message CreateUserRequest {
string email = 1;
optional string username = 2;
optional string password = 3;
}
message CreateUserResponse {
User user = 1;
}
message UpdateUserRequest {
string id = 1;
optional string username = 2;
optional string avatar = 3;
optional string role = 4;
optional string plan_id = 5;
}
message UpdateUserResponse {
User user = 1;
}
message DeleteUserRequest {
string id = 1;
}
message DeleteUserResponse {
bool success = 1;
}
// ─── Preferences Messages ────────────────────────────────────────────────────
message GetPreferencesRequest {
string user_id = 1;
}
message GetPreferencesResponse {
Preferences preferences = 1;
}
message UpsertPreferencesRequest {
Preferences preferences = 1;
}
message UpsertPreferencesResponse {
Preferences preferences = 1;
}
// ─── Core Models ─────────────────────────────────────────────────────────────
message User {
string id = 1;
string email = 2;
string password = 3;
optional string username = 4;
optional string avatar = 5;
optional string role = 6;
optional string google_id = 7;
int64 storage_used = 8;
optional string plan_id = 9;
optional google.protobuf.Timestamp created_at = 10;
google.protobuf.Timestamp updated_at = 11;
}
message Preferences {
string user_id = 1;
optional string language = 2;
optional string locale = 3;
optional bool email_notifications = 4;
optional bool push_notifications = 5;
optional bool marketing_notifications = 6;
optional bool telegram_notifications = 7;
optional bool autoplay = 8;
optional bool loop = 9;
optional bool muted = 10;
optional bool show_controls = 11;
optional bool pip = 12;
optional bool airplay = 13;
optional bool chromecast = 14;
optional bool encrytion_m3u8 = 15;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

7
scripts/gen-nacl-keys.ts Normal file
View File

@@ -0,0 +1,7 @@
// scripts/gen-nacl-keys.ts
import nacl from "tweetnacl";
const kp = nacl.box.keyPair();
console.log("PUBLIC_KEY_BASE64=", Buffer.from(kp.publicKey).toString("base64"));
console.log("SECRET_KEY_BASE64=", Buffer.from(kp.secretKey).toString("base64"));

View File

@@ -1,688 +0,0 @@
/* eslint-disable */
/* tslint:disable */
// @ts-nocheck
/*
* ---------------------------------------------------------------
* ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ##
* ## ##
* ## AUTHOR: acacode ##
* ## SOURCE: https://github.com/acacode/swagger-typescript-api ##
* ---------------------------------------------------------------
*/
import { customFetch } from "@httpClientAdapter";
export interface AuthForgotPasswordRequest {
email: string;
}
export interface AuthLoginRequest {
email: string;
password: string;
}
export interface AuthRegisterRequest {
email: string;
/** @minLength 6 */
password: string;
username: string;
}
export interface AuthResetPasswordRequest {
/** @minLength 6 */
new_password: string;
token: string;
}
export interface ModelPlan {
cycle?: string;
description?: string;
duration_limit?: number;
features?: string;
id?: string;
is_active?: boolean;
name?: string;
price?: number;
quality_limit?: string;
storage_limit?: number;
upload_limit?: number;
}
export interface ModelUser {
avatar?: string;
created_at?: string;
email?: string;
google_id?: string;
id?: string;
password?: string;
plan_id?: string;
role?: string;
storage_used?: number;
updated_at?: string;
username?: string;
}
export interface ModelVideo {
created_at?: string;
description?: string;
duration?: number;
format?: string;
hls_path?: string;
hls_token?: string;
id?: string;
name?: string;
processing_status?: string;
size?: number;
status?: string;
storage_type?: string;
thumbnail?: string;
title?: string;
updated_at?: string;
url?: string;
user_id?: string;
views?: number;
}
export interface PaymentCreatePaymentRequest {
amount: number;
plan_id: string;
}
export interface ResponseResponse {
code?: number;
message?: string;
}
export interface VideoCreateVideoRequest {
description?: string;
/** Maybe client knows, or we process later */
duration?: number;
format?: string;
size: number;
title: string;
/** The S3 Key or Full URL */
url: string;
}
export interface VideoUploadURLRequest {
content_type: string;
filename: string;
size: number;
}
export type QueryParamsType = Record<string | number, any>;
export type ResponseFormat = keyof Omit<Body, "body" | "bodyUsed">;
export interface FullRequestParams extends Omit<RequestInit, "body"> {
/** set parameter to `true` for call `securityWorker` for this request */
secure?: boolean;
/** request path */
path: string;
/** content type of request body */
type?: ContentType;
/** query params */
query?: QueryParamsType;
/** format of response (i.e. response.json() -> format: "json") */
format?: ResponseFormat;
/** request body */
body?: unknown;
/** base url */
baseUrl?: string;
/** request cancellation token */
cancelToken?: CancelToken;
}
export type RequestParams = Omit<
FullRequestParams,
"body" | "method" | "query" | "path"
>;
export interface ApiConfig<SecurityDataType = unknown> {
baseUrl?: string;
baseApiParams?: Omit<RequestParams, "baseUrl" | "cancelToken" | "signal">;
securityWorker?: (
securityData: SecurityDataType | null,
) => Promise<RequestParams | void> | RequestParams | void;
customFetch?: typeof fetch;
}
export interface HttpResponse<D extends unknown, E extends unknown = unknown>
extends Response {
data: D;
error: E;
}
type CancelToken = Symbol | string | number;
export enum ContentType {
Json = "application/json",
JsonApi = "application/vnd.api+json",
FormData = "multipart/form-data",
UrlEncoded = "application/x-www-form-urlencoded",
Text = "text/plain",
}
export class HttpClient<SecurityDataType = unknown> {
public baseUrl: string = "";
private securityData: SecurityDataType | null = null;
private securityWorker?: ApiConfig<SecurityDataType>["securityWorker"];
private abortControllers = new Map<CancelToken, AbortController>();
private customFetch = (...fetchParams: Parameters<typeof fetch>) =>
fetch(...fetchParams);
private baseApiParams: RequestParams = {
credentials: "same-origin",
headers: {},
redirect: "follow",
referrerPolicy: "no-referrer",
};
constructor(apiConfig: ApiConfig<SecurityDataType> = {}) {
Object.assign(this, apiConfig);
}
public setSecurityData = (data: SecurityDataType | null) => {
this.securityData = data;
};
protected encodeQueryParam(key: string, value: any) {
const encodedKey = encodeURIComponent(key);
return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`;
}
protected addQueryParam(query: QueryParamsType, key: string) {
return this.encodeQueryParam(key, query[key]);
}
protected addArrayQueryParam(query: QueryParamsType, key: string) {
const value = query[key];
return value.map((v: any) => this.encodeQueryParam(key, v)).join("&");
}
protected toQueryString(rawQuery?: QueryParamsType): string {
const query = rawQuery || {};
const keys = Object.keys(query).filter(
(key) => "undefined" !== typeof query[key],
);
return keys
.map((key) =>
Array.isArray(query[key])
? this.addArrayQueryParam(query, key)
: this.addQueryParam(query, key),
)
.join("&");
}
protected addQueryParams(rawQuery?: QueryParamsType): string {
const queryString = this.toQueryString(rawQuery);
return queryString ? `?${queryString}` : "";
}
private contentFormatters: Record<ContentType, (input: any) => any> = {
[ContentType.Json]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.JsonApi]: (input: any) =>
input !== null && (typeof input === "object" || typeof input === "string")
? JSON.stringify(input)
: input,
[ContentType.Text]: (input: any) =>
input !== null && typeof input !== "string"
? JSON.stringify(input)
: input,
[ContentType.FormData]: (input: any) => {
if (input instanceof FormData) {
return input;
}
return Object.keys(input || {}).reduce((formData, key) => {
const property = input[key];
formData.append(
key,
property instanceof Blob
? property
: typeof property === "object" && property !== null
? JSON.stringify(property)
: `${property}`,
);
return formData;
}, new FormData());
},
[ContentType.UrlEncoded]: (input: any) => this.toQueryString(input),
};
protected mergeRequestParams(
params1: RequestParams,
params2?: RequestParams,
): RequestParams {
return {
...this.baseApiParams,
...params1,
...(params2 || {}),
headers: {
...(this.baseApiParams.headers || {}),
...(params1.headers || {}),
...((params2 && params2.headers) || {}),
},
};
}
protected createAbortSignal = (
cancelToken: CancelToken,
): AbortSignal | undefined => {
if (this.abortControllers.has(cancelToken)) {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
return abortController.signal;
}
return void 0;
}
const abortController = new AbortController();
this.abortControllers.set(cancelToken, abortController);
return abortController.signal;
};
public abortRequest = (cancelToken: CancelToken) => {
const abortController = this.abortControllers.get(cancelToken);
if (abortController) {
abortController.abort();
this.abortControllers.delete(cancelToken);
}
};
public request = async <T = any, E = any>({
body,
secure,
path,
type,
query,
format,
baseUrl,
cancelToken,
...params
}: FullRequestParams): Promise<HttpResponse<T, E>> => {
const secureParams =
((typeof secure === "boolean" ? secure : this.baseApiParams.secure) &&
this.securityWorker &&
(await this.securityWorker(this.securityData))) ||
{};
const requestParams = this.mergeRequestParams(params, secureParams);
const queryString = query && this.toQueryString(query);
const payloadFormatter = this.contentFormatters[type || ContentType.Json];
const responseFormat = format || requestParams.format;
return this.customFetch(
`${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`,
{
...requestParams,
headers: {
...(requestParams.headers || {}),
...(type && type !== ContentType.FormData
? { "Content-Type": type }
: {}),
},
signal:
(cancelToken
? this.createAbortSignal(cancelToken)
: requestParams.signal) || null,
body:
typeof body === "undefined" || body === null
? null
: payloadFormatter(body)
},
).then(async (response) => {
const r = response as HttpResponse<T, E>;
r.data = null as unknown as T;
r.error = null as unknown as E;
const responseToParse = responseFormat ? response.clone() : response;
const data = !responseFormat
? r
: await responseToParse[responseFormat]()
.then((data) => {
if (r.ok) {
r.data = data;
} else {
r.error = data;
}
return r;
})
.catch((e) => {
r.error = e;
return r;
});
if (cancelToken) {
this.abortControllers.delete(cancelToken);
}
if (!response.ok) throw data;
return data;
});
};
}
/**
* @title Stream API
* @version 1.0
* @license Apache 2.0 (http://www.apache.org/licenses/LICENSE-2.0.html)
* @termsOfService http://swagger.io/terms/
* @contact API Support <support@swagger.io> (http://www.swagger.io/support)
*
* This is the API server for Stream application.
*/
export class Api<
SecurityDataType extends unknown,
> extends HttpClient<SecurityDataType> {
auth = {
/**
* @description Request password reset link
*
* @tags auth
* @name ForgotPasswordCreate
* @summary Forgot Password
* @request POST:/auth/forgot-password
*/
forgotPasswordCreate: (
request: AuthForgotPasswordRequest,
params: RequestParams = {},
) =>
this.request<ResponseResponse, ResponseResponse>({
path: `/auth/forgot-password`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Callback for Google Login
*
* @tags auth
* @name GoogleCallbackList
* @summary Google Callback
* @request GET:/auth/google/callback
*/
googleCallbackList: (params: RequestParams = {}) =>
this.request<
ResponseResponse & {
data?: ModelUser;
},
ResponseResponse
>({
path: `/auth/google/callback`,
method: "GET",
...params,
}),
/**
* @description Redirect to Google for Login
*
* @tags auth
* @name GoogleLoginList
* @summary Google Login
* @request GET:/auth/google/login
*/
googleLoginList: (params: RequestParams = {}) =>
this.request<any, void>({
path: `/auth/google/login`,
method: "GET",
...params,
}),
/**
* @description Login with email and password
*
* @tags auth
* @name LoginCreate
* @summary Login
* @request POST:/auth/login
*/
loginCreate: (request: AuthLoginRequest, params: RequestParams = {}) =>
this.request<
ResponseResponse & {
data?: ModelUser;
},
ResponseResponse
>({
path: `/auth/login`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Logout user and clear cookies
*
* @tags auth
* @name LogoutCreate
* @summary Logout
* @request POST:/auth/logout
*/
logoutCreate: (params: RequestParams = {}) =>
this.request<ResponseResponse, any>({
path: `/auth/logout`,
method: "POST",
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Register a new user
*
* @tags auth
* @name RegisterCreate
* @summary Register
* @request POST:/auth/register
*/
registerCreate: (
request: AuthRegisterRequest,
params: RequestParams = {},
) =>
this.request<ResponseResponse, ResponseResponse>({
path: `/auth/register`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Reset password using token
*
* @tags auth
* @name ResetPasswordCreate
* @summary Reset Password
* @request POST:/auth/reset-password
*/
resetPasswordCreate: (
request: AuthResetPasswordRequest,
params: RequestParams = {},
) =>
this.request<ResponseResponse, ResponseResponse>({
path: `/auth/reset-password`,
method: "POST",
body: request,
type: ContentType.Json,
format: "json",
...params,
}),
};
payments = {
/**
* @description Create a new payment
*
* @tags payment
* @name PaymentsCreate
* @summary Create Payment
* @request POST:/payments
* @secure
*/
paymentsCreate: (
request: PaymentCreatePaymentRequest,
params: RequestParams = {},
) =>
this.request<ResponseResponse, ResponseResponse>({
path: `/payments`,
method: "POST",
body: request,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
};
plans = {
/**
* @description Get all active plans
*
* @tags plan
* @name PlansList
* @summary List Plans
* @request GET:/plans
* @secure
*/
plansList: (params: RequestParams = {}) =>
this.request<
ResponseResponse & {
data: {
plans: ModelPlan[];
}
},
ResponseResponse
>({
path: `/plans`,
method: "GET",
secure: true,
format: "json",
...params,
}),
};
videos = {
/**
* @description Get paginated videos
*
* @tags video
* @name VideosList
* @summary List Videos
* @request GET:/videos
* @secure
*/
videosList: (
query?: {
/**
* Page number
* @default 1
*/
page?: number;
/**
* Page size
* @default 10
*/
limit?: number;
},
params: RequestParams = {},
) =>
this.request<ResponseResponse & {
data: {
limit: number;
page: number;
total: number;
videos: ModelVideo[];
}
}, ResponseResponse>({
path: `/videos`,
method: "GET",
query: query,
secure: true,
format: "json",
...params,
}),
/**
* @description Create video record after upload
*
* @tags video
* @name VideosCreate
* @summary Create Video
* @request POST:/videos
* @secure
*/
videosCreate: (
request: VideoCreateVideoRequest,
params: RequestParams = {},
) =>
this.request<
ResponseResponse & {
data?: ModelVideo;
},
ResponseResponse
>({
path: `/videos`,
method: "POST",
body: request,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Generate presigned URL for video upload
*
* @tags video
* @name UploadUrlCreate
* @summary Get Upload URL
* @request POST:/videos/upload-url
* @secure
*/
uploadUrlCreate: (
request: VideoUploadURLRequest,
params: RequestParams = {},
) =>
this.request<ResponseResponse, ResponseResponse>({
path: `/videos/upload-url`,
method: "POST",
body: request,
secure: true,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Get video details by ID
*
* @tags video
* @name VideosDetail
* @summary Get Video
* @request GET:/videos/{id}
* @secure
*/
videosDetail: (id: string, params: RequestParams = {}) =>
this.request<
ResponseResponse & {
data?: ModelVideo;
},
ResponseResponse
>({
path: `/videos/${id}`,
method: "GET",
secure: true,
format: "json",
...params,
}),
};
}
export const client = new Api({
baseUrl: 'r',
// baseUrl: 'https://api.pipic.fun',
customFetch
});

View File

@@ -1,6 +1,78 @@
export const customFetch = (url: string, options: RequestInit) => { import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
return fetch(url, { import { Result } from "@hiogawa/utils";
...options,
credentials: "include", const GET_PAYLOAD_PARAM = "payload";
});
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
JSON?: Partial<JsonTransformer>;
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
const JSON: JsonTransformer = {
parse: globalThis.JSON.parse,
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
...opts.JSON,
};
return {
send: async (data) => {
const url = [opts.url, data.path].join("/");
const extraHeaders = opts.headers ? await opts.headers() : {};
const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
);
} else {
req = new Request(url, {
method: "POST",
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders
},
credentials: "include",
});
}
let res: Response;
res = await fetch(req);
if (!res.ok) {
// throw new Error(`HTTP error: ${res.status}`);
throw new Error(
JSON.stringify({
status: res.status,
statusText: res.statusText,
data: { message: await res.text() },
internal: true,
})
);
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);
}
return result.value;
},
};
} }

View File

@@ -1,31 +1,88 @@
import { TinyRpcClientAdapter, TinyRpcError } from "@hiogawa/tiny-rpc";
import { Result } from "@hiogawa/utils";
import { tryGetContext } from "hono/context-storage"; import { tryGetContext } from "hono/context-storage";
export const customFetch = (url: string, options: RequestInit) => { const GET_PAYLOAD_PARAM = "payload";
options.credentials = "include"; export const baseAPIURL = "https://api.pipic.fun";
const c = tryGetContext<any>();
if (!c) {
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> = {}; export function httpClientAdapter(opts: {
reqHeaders.forEach((value, key) => { url: string;
mergedHeaders[key] = value; pathsForGET?: string[];
}); JSON?: Partial<JsonTransformer>;
options.headers = { headers?: () => Promise<Record<string, string>> | Record<string, string>;
...mergedHeaders, }): TinyRpcClientAdapter {
...(options.headers as Record<string, string>), const JSON: JsonTransformer = {
parse: globalThis.JSON.parse,
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
...opts.JSON,
}; };
return {
send: async (data) => {
const url = [opts.url, data.path].join("/");
const extraHeaders = opts.headers ? await opts.headers() : {};
const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
);
} else {
req = new Request(url, {
method: "POST",
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders,
},
credentials: "include",
});
}
let res: Response;
if (import.meta.env.SSR) {
const c = tryGetContext<any>();
if (!c) {
throw new Error("Hono context not found in SSR");
}
Object.entries(c.req.header()).forEach(([k, v]) => {
req.headers.append(k, v);
});
res = await c.get("fetch")(req);
} else {
res = await fetch(req);
}
const apiUrl = ["https://api.pipic.fun", url.replace(/^r/, "")].join(""); if (!res.ok) {
return fetch(apiUrl, options).then(async (res) => { // throw new Error(`HTTP error: ${res.status}`);
res.headers.getSetCookie()?.forEach((cookie) => { throw new Error(
c.header("Set-Cookie", cookie); JSON.stringify({
}); status: res.status,
return res; statusText: res.statusText,
}); data: { message: await res.text() },
}; internal: true,
})
);
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);
}
return result.value;
},
};
}

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;
}

39
src/api/rpcclient.ts Normal file
View File

@@ -0,0 +1,39 @@
import type { RpcRoutes } from "@/server/routes/rpc";
import { proxyTinyRpc } from "@hiogawa/tiny-rpc";
import { httpClientAdapter } from "@httpClientAdapter";
const endpoint = "/rpc";
const publicEndpoint = "/rpc-public";
const url = import.meta.env.SSR ? "http://localhost" : "";
const publicMethods = ["login", "register", "forgotPassword", "resetPassword", "getGoogleLoginUrl"];
// src/client/trpc-client-transformer.ts
import {
clientJSON
} from "@/shared/secure-json-transformer";
// export function createTrpcClientTransformer(cfg: ServerPublicKeyConfig) {
// return {
// input: ,
// output: superjson,
// };
// }
// const secureConfig = await fetch("/trpc-secure-config").then((r) => r.json());
export const client = proxyTinyRpc<RpcRoutes>({
adapter: {
send: async (data) => {
const targetEndpoint = publicMethods.includes(data.path) ? publicEndpoint : endpoint;
return await httpClientAdapter({
url: `${url}${targetEndpoint}`,
pathsForGET: ["health"],
JSON: {
// parse: clientJSON.parse,
parse: (v, fn) => JSON.parse(v),
// stringify: clientJSON.stringify,
stringify: (v, fn) => JSON.stringify(v),
},
headers: () => Promise.resolve({})
}).send(data);
},
},
});

View File

@@ -1,11 +1,22 @@
import { createApp } from './main'; import { hydrateQueryCache } from '@pinia/colada';
import 'uno.css'; 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() { async function render() {
const { app, router } = createApp(); const appData = readAppData();
router.isReady().then(() => { const { app, router, queryCache, pinia } = await createApp(appData.$locale);
app.mount('body', true) pinia.use(PiniaSharedState({ enable: true, initialize: true }));
}) hydrateQueryCache(queryCache, appData.$colada || {});
await router.isReady();
app.mount('body', true);
} }
render().catch((error) => { render().catch((error) => {
console.error('Error during app initialization:', error) console.error('Error during app initialization:', error);
}) });

View File

@@ -0,0 +1,23 @@
<script setup lang="ts">
import { useRouteLoading } from '@/composables/useRouteLoading';
import { computed } from 'vue';
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 transition-[transform,opacity] duration-200 ease-out"
:style="barStyle"
/>
</div>
</template>

View File

@@ -1,4 +1,5 @@
<script lang="ts" setup> <script lang="ts" setup>
import Upload from "@/routes/upload/Upload.vue";
import DashboardNav from "./DashboardNav.vue"; import DashboardNav from "./DashboardNav.vue";
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue"; import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
@@ -6,8 +7,8 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
<template> <template>
<DashboardNav /> <DashboardNav />
<main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-page md:ps-18"> <main class="flex flex-1 flex-col transition-all duration-300 ease-in-out bg-white md:ps-18">
<div class=":uno: flex-1 overflow-auto p-4 bg-page rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]"> <div class=":uno: flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
<router-view v-slot="{ Component }"> <router-view v-slot="{ Component }">
<Transition enter-active-class="transition-all duration-300 ease-in-out" <Transition enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="opacity-0 transform translate-y-4" enter-from-class="opacity-0 transform translate-y-4"
@@ -20,5 +21,6 @@ import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
</router-view> </router-view>
</div> </div>
<GlobalUploadIndicator /> <GlobalUploadIndicator />
<Upload />
</main> </main>
</template> </template>

View File

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

View File

@@ -1,103 +1,154 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue';
import { useUploadQueue } from '@/composables/useUploadQueue'; import { useUploadQueue } from '@/composables/useUploadQueue';
import { useRoute, useRouter } from 'vue-router';
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue'; 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 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(() => { const isVisible = computed(() => items.value.length > 0);
// Show if there are items AND we are NOT on the upload page
return items.value.length > 0 && route.path !== '/upload';
});
const progress = computed(() => { const overallProgress = computed(() => {
if (items.value.length === 0) return 0; if (items.value.length === 0) return 0;
const totalProgress = items.value.reduce((acc, item) => acc + (item.progress || 0), 0); const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
return Math.round(totalProgress / items.value.length); return Math.round(total / items.value.length);
}); });
const isUploading = computed(() => { const isUploading = computed(() =>
return items.value.some(i => i.status === 'uploading' || i.status === 'fetching'); 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> </script>
<template> <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 --> <div v-if="isVisible"
<Transition enter-active-class="transition duration-200 ease-out" 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"
enter-from-class="opacity-0 translate-y-2 scale-95" enter-to-class="opacity-100 translate-y-0 scale-100" style="max-height: 540px;">
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 <!-- Header bar -->
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"> <div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" :minimal="true" @click="isCollapsed = !isCollapsed">
class="border-b border-slate-100 last:border-0 !rounded-none" />
</div>
</div>
</Transition>
<!-- Floating Button --> <!-- Status icon -->
<button @click="toggleOpen" <div class="relative w-6 h-6 shrink-0">
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"> <svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
<!-- Progress Ring --> <circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
<div class="relative w-10 h-10 flex items-center justify-center"> <path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
<svg class="w-full h-full -rotate-90 text-slate-100" viewBox="0 0 36 36"> </svg>
<path class="stroke-current" fill="none" stroke-width="3" <svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
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" /> stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
</svg>
<svg class="absolute inset-0 w-full h-full -rotate-90 text-accent transition-all duration-500"
viewBox="0 0 36 36" :style="{ strokeDasharray: `${progress}, 100` }">
<path class="stroke-current" fill="none" stroke-width="3" stroke-linecap="round"
d="M18 2.0845 a 15.9155 15.9155 0 0 1 0 31.831 a 15.9155 15.9155 0 0 1 0 -31.831" />
</svg>
<div class="absolute inset-0 flex items-center justify-center text-accent">
<svg v-if="!isUploading && completeCount === items.length" xmlns="http://www.w3.org/2000/svg"
class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 6 9 17l-5-5" /> <path d="M20 6 9 17l-5-5" />
</svg> </svg>
<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> </div>
<div class="text-left"> <!-- Overall progress bar -->
<div class="text-sm font-bold text-slate-800 group-hover:text-accent transition-colors"> <div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
{{ isUploading ? 'Uploading...' : (completeCount === items.length ? 'Completed' : 'Pending') }} <div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
</div>
<div class="text-xs text-slate-500">
{{ completeCount }} / {{ items.length }} files
</div> </div>
</div> </div>
<div v-if="pendingCount" <!-- File list -->
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"> <Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
{{ pendingCount }} enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
</div> leave-from-class="opacity-100" leave-to-class="opacity-0">
</button> <div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
</div> <div class="p-3 flex flex-col gap-2">
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
@cancel="cancelItem($event)" />
</div>
</div>
</Transition>
</div>
</Transition>
</template> </template>

View File

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

View File

@@ -0,0 +1,67 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { useTranslation } from 'i18next-vue'
import { onBeforeUnmount, onMounted } from 'vue'
const { t } = useTranslation()
const { isOffline, startListening, stopListening } = useNetworkStatus()
onMounted(() => {
startListening()
})
onBeforeUnmount(() => {
stopListening()
})
function reloadPage() {
if (typeof window === 'undefined') return
window.location.reload()
}
</script>
<template>
<div
v-if="isOffline"
class="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-950/80 px-6 backdrop-blur-sm"
role="alert"
aria-live="assertive"
>
<div class="w-full max-w-md rounded-2xl border border-border bg-white p-8 text-center shadow-2xl">
<div class="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-danger/10 text-danger">
<svg
class="h-8 w-8"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M2 8.82a15 15 0 0 1 20 0" />
<path d="M5 12.86a10 10 0 0 1 14 0" />
<path d="M8.5 16.43a5 5 0 0 1 7 0" />
<path d="M12 20h.01" />
<path d="M3 3l18 18" />
</svg>
</div>
<h2 class="text-xl font-semibold text-foreground">
{{ t('network.offline.title') }}
</h2>
<p class="mt-3 text-sm leading-6 text-foreground/70">
{{ t('network.offline.description') }}
</p>
<div class="mt-6 flex justify-center">
<AppButton @click="reloadPage">
{{ t('network.offline.action') }}
</AppButton>
</div>
</div>
</div>
</template>

View File

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

View File

@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { useTranslation } from 'i18next-vue';
import { VNode } from 'vue'; import { VNode } from 'vue';
interface Trend { interface Trend {
@@ -6,7 +7,7 @@ interface Trend {
isPositive: boolean; isPositive: boolean;
} }
interface Props { export interface StatProps {
title: string; title: string;
value: string | number; value: string | number;
icon?: string | VNode; icon?: string | VNode;
@@ -14,10 +15,12 @@ interface Props {
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
} }
withDefaults(defineProps<Props>(), { withDefaults(defineProps<StatProps>(), {
color: 'primary' color: 'primary'
}); });
const { t } = useTranslation();
// const gradients = { // const gradients = {
// primary: 'from-primary/20 to-primary/5', // primary: 'from-primary/20 to-primary/5',
// success: 'from-success/20 to-success/5', // success: 'from-success/20 to-success/5',
@@ -37,7 +40,7 @@ const iconColors = {
<template> <template>
<div :class="[ <div :class="[
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-surface', 'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-header',
// gradients[color], // gradients[color],
'border border-gray-300 transition-all duration-300', 'border border-gray-300 transition-all duration-300',
// 'group cursor-pointer' // 'group cursor-pointer'
@@ -46,7 +49,7 @@ const iconColors = {
<div class="relative z-10"> <div class="relative z-10">
<div class="flex items-start justify-between mb-3"> <div class="flex items-start justify-between mb-3">
<div> <div>
<p class="text-sm font-medium text-gray-600 mb-1">{{ title }}</p> <p class="text-sm font-medium text-gray-600 mb-1">{{ $t(title) }}</p>
<p class="text-3xl font-bold text-gray-900">{{ value }}</p> <p class="text-3xl font-bold text-gray-900">{{ value }}</p>
</div> </div>
@@ -76,7 +79,7 @@ const iconColors = {
</svg> </svg>
{{ Math.abs(trend.value) }}% {{ Math.abs(trend.value) }}%
</span> </span>
<span class="text-gray-500">vs last month</span> <span class="text-gray-500">{{ t('overview.stats.trendVsLastMonth') }}</span>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -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

@@ -6,12 +6,12 @@
fill="#a6acb9" /> fill="#a6acb9" />
<path <path
d="M74 42c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32V74c0-18-14-32-32-32H74zM10 74c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74zm208 256v-80h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z" d="M74 42c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32V74c0-18-14-32-32-32H74zM10 74c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H74c-35 0-64-29-64-64V74zm208 256v-80h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -226 468 468"> <svg v-else xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -226 468 468">
<path <path
d="M64-184c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32v-320c0-18-14-32-32-32H64zM0-152c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H64c-35 0-64-29-64-64v-320zm208 256V24h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z" d="M64-184c-18 0-32 14-32 32v320c0 18 14 32 32 32h320c18 0 32-14 32-32v-320c0-18-14-32-32-32H64zM0-152c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H64c-35 0-64-29-64-64v-320zm208 256V24h-80c-9 0-16-7-16-16s7-16 16-16h80v-80c0-9 7-16 16-16s16 7 16 16v80h80c9 0 16 7 16 16s-7 16-16 16h-80v80c0 9-7 16-16 16s-16-7-16-16z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

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="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><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="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -242 500 516"><path d="M448-194v404l-26-24c-50-47-114-75-182-81V-89c68-6 132-34 182-81l26-24zM240 137c60 6 116 31 160 72l34 32c5 4 12 7 19 7 15 0 27-12 27-27v-425c0-16-12-28-27-28-7 0-14 3-19 8l-34 31c-50 47-116 73-185 73h-87C57-120 0-63 0 8c0 60 41 110 96 124v84c0 27 22 48 48 48h48c27 0 48-21 48-48v-79zm-40-1h8v80c0 9-7 16-16 16h-48c-9 0-16-7-16-16v-80h72zm0-224h8v192h-80c-53 0-96-43-96-96s43-96 96-96h72z" 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,11 +1,11 @@
<template> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="#a6acb9"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="#1e3050"/></svg> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
<path <path
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z" d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();
</script> </script>

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,6 +1,6 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="#a6acb9"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="#1e3050"/></svg> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 580 524"><path d="M10 234v112c0 46 38 84 84 84s84-38 84-84V234c0-46-38-84-84-84s-84 38-84 84zM206 94v252c0 46 38 84 84 84s84-38 84-84V94c0-46-38-84-84-84s-84 38-84 84zm196 56v196c0 46 38 84 84 84s84-38 84-84V150c0-46-38-84-84-84s-84 38-84 84z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 500c0-8 6-14 14-14h532c8 0 14 6 14 14s-6 14-14 14H24c-8 0-14-6-14-14z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="#1e3050"/></svg> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 532 468"><path d="M272-184c9 0 16 7 16 16v352c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-352c0-9 7-16 16-16h32zm-32-32c-26 0-48 22-48 48v352c0 27 22 48 48 48h32c27 0 48-21 48-48v-352c0-26-21-48-48-48h-32zM80 8c9 0 16 7 16 16v160c0 9-7 16-16 16H48c-9 0-16-7-16-16V24c0-9 7-16 16-16h32zM48-24C22-24 0-2 0 24v160c0 27 22 48 48 48h32c27 0 48-21 48-48V24c0-26-21-48-48-48H48zm384-96h32c9 0 16 7 16 16v288c0 9-7 16-16 16h-32c-9 0-16-7-16-16v-288c0-9 7-16 16-16zm-48 16v288c0 27 22 48 48 48h32c27 0 48-21 48-48v-288c0-26-21-48-48-48h-32c-26 0-48 22-48 48z" fill="currentColor"/></svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();

View File

@@ -1,3 +1,11 @@
<script lang="ts" setup>
defineProps<{
filled?: boolean;
}>();
</script>
<template> <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> </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

@@ -1,6 +1,6 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#a6acb9"/><path d="M10 106h512v64H10zm0 0z" fill="#1e3050"/></svg> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)"/><path d="M10 106h512v64H10zm0 0z" fill="var(--colors-primary-DEFAULT)"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="#1e3050"/></svg> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -194 532 404"><path d="M448-136c9 0 16 7 16 16v32H48v-32c0-9 7-16 16-16h384zm16 112v160c0 9-7 16-16 16H64c-9 0-16-7-16-16V-24h416zM64-184c-35 0-64 29-64 64v256c0 35 29 64 64 64h384c35 0 64-29 64-64v-256c0-35-29-64-64-64H64zM80 96c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24h-48c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="currentColor"/></svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();

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="currentColor" />
</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="currentColor" />
</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

@@ -1,6 +1,6 @@
<template> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 503"><path d="M10 397v32c0 35 29 64 64 64h320c35 0 64-29 64-64v-32c0-35-29-64-64-64H266v32c0 18-14 32-32 32s-32-14-32-32v-32H74c-35 0-64 29-64 64zm392 16c0 13-11 24-24 24s-24-11-24-24 11-24 24-24 24 11 24 24z" fill="#a6acb9"/><path d="M234 397c18 0 32-14 32-32V122l41 42c13 12 33 12 46 0 12-13 12-33 0-46l-96-96c-13-12-33-12-46 0l-96 96c-12 13-12 33 0 46 13 12 33 12 46 0l41-42v243c0 18 14 32 32 32z" fill="#1e3050"/></svg> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 503"><path d="M10 397v32c0 35 29 64 64 64h320c35 0 64-29 64-64v-32c0-35-29-64-64-64H266v32c0 18-14 32-32 32s-32-14-32-32v-32H74c-35 0-64 29-64 64zm392 16c0 13-11 24-24 24s-24-11-24-24 11-24 24-24 24 11 24 24z" fill="#a6acb9"/><path d="M234 397c18 0 32-14 32-32V122l41 42c13 12 33 12 46 0 12-13 12-33 0-46l-96-96c-13-12-33-12-46 0l-96 96c-12 13-12 33 0 46 13 12 33 12 46 0l41-42v243c0 18 14 32 32 32z" fill="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -260 468 502"><path d="M248 80c0 13-11 24-24 24s-24-11-24-24v-246l-63 63c-9 9-25 9-34 0s-9-25 0-34l104-104c9-9 25-9 34 0l104 104c9 9 9 25 0 34s-25 9-34 0l-63-63V80zm-96-8H64c-9 0-16 7-16 16v80c0 9 7 16 16 16h320c9 0 16-7 16-16V88c0-9-7-16-16-16h-88V24h88c35 0 64 29 64 64v80c0 35-29 64-64 64H64c-35 0-64-29-64-64V88c0-35 29-64 64-64h88v48zm168 56c0-13 11-24 24-24s24 11 24 24-11 24-24 24-24-11-24-24z" fill="#1e3050"/></svg> <svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -260 468 502"><path d="M248 80c0 13-11 24-24 24s-24-11-24-24v-246l-63 63c-9 9-25 9-34 0s-9-25 0-34l104-104c9-9 25-9 34 0l104 104c9 9 9 25 0 34s-25 9-34 0l-63-63V80zm-96-8H64c-9 0-16 7-16 16v80c0 9 7 16 16 16h320c9 0 16-7 16-16V88c0-9-7-16-16-16h-88V24h88c35 0 64 29 64 64v80c0 35-29 64-64 64H64c-35 0-64-29-64-64V88c0-35 29-64 64-64h88v48zm168 56c0-13 11-24 24-24s24 11 24 24-11 24-24 24-24-11-24-24z" fill="currentColor"/></svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();

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,17 +1,17 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
<path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z" <path d="M61 281c2-1 4-3 6-5L269 89l202 187c2 2 4 4 6 5v180c0 35-29 64-64 64H125c-35 0-64-29-64-64V281z"
fill="#a6acb9" /> fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path <path
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z" d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
fill="#1e3050" /> fill="var(--colors-primary-DEFAULT)" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
<path <path
d="M272-242c-9-8-23-8-32 0L8-34C-2-25-3-10 6 0s24 11 34 2l8-7v205c0 35 29 64 64 64h288c35 0 64-29 64-64V-5l8 7c10 9 25 8 34-2s8-25-2-34L272-242zM416-48v248c0 9-7 16-16 16H112c-9 0-16-7-16-16V-48l160-144L416-48z" d="M272-242c-9-8-23-8-32 0L8-34C-2-25-3-10 6 0s24 11 34 2l8-7v205c0 35 29 64 64 64h288c35 0 64-29 64-64V-5l8 7c10 9 25 8 34-2s8-25-2-34L272-242zM416-48v248c0 9-7 16-16 16H112c-9 0-16-7-16-16V-48l160-144L416-48z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();
</script> </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">
<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

@@ -6,12 +6,12 @@
fill="#a6acb9" /> fill="#a6acb9" />
<path <path
d="M394 42c18 0 32 14 32 32v64H42V74c0-18 14-32 32-32h320zM42 394V170h96v256H74c-18 0-32-14-32-32zm128 32V170h256v224c0 18-14 32-32 32H170zM74 10c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64V74c0-35-29-64-64-64H74z" d="M394 42c18 0 32 14 32 32v64H42V74c0-18 14-32 32-32h320zM42 394V170h96v256H74c-18 0-32-14-32-32zm128 32V170h256v224c0 18-14 32-32 32H170zM74 10c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64V74c0-35-29-64-64-64H74z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" viewBox="-10 -226 468 468"> <svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" viewBox="-10 -226 468 468">
<path <path
d="M384-184c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32h320zM32 168V-56h96v256H64c-18 0-32-14-32-32zm128 32V-56h256v224c0 18-14 32-32 32H160zM64-216c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64z" d="M384-184c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32h320zM32 168V-56h96v256H64c-18 0-32-14-32-32zm128 32V-56h256v224c0 18-14 32-32 32H160zM64-216c-35 0-64 29-64 64v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

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> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="#1e3050"/></svg> <svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 404"><path d="M74 170v64c0 53 43 96 96 96h96v64h64v-64h96c53 0 96-43 96-96v-64c0-53-43-96-96-96h-96V10h-64v64h-96c-53 0-96 43-96 96zm96 0h256v64H170v-64z" fill="#a6acb9"/><path d="M170 10C82 10 10 82 10 170v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96V10h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM202 170h-32v64h256v-64H202z" fill="currentColor"/></svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -194 596 404"><path d="M160-184C72-184 0-112 0-24v64c0 88 72 160 160 160h96v-64h-96c-53 0-96-43-96-96v-64c0-53 43-96 96-96h96v-64h-96zm256 384c88 0 160-72 160-160v-64c0-88-72-160-160-160h-96v64h96c53 0 96 43 96 96v64c0 53-43 96-96 96h-96v64h96zM192-24h-32v64h256v-64H192z" fill="#1e3050"/></svg> <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> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); 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> <template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" <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>
stroke="none">
<path
d="M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.488.488 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.484.484 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L5.09 8.87a.484.484 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.48.48 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.21.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.03-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z" />
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" <svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path <path

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

@@ -2,15 +2,15 @@
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
<path <path
d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z" d="M10 314c0-63 41-117 98-136-1-8-2-16-2-24 0-79 65-144 144-144 55 0 104 31 128 77 14-8 30-13 48-13 53 0 96 43 96 96 0 16-4 31-10 44 44 20 74 64 74 116 0 71-57 128-128 128H154c-79 0-144-64-144-144zm199-73c-9 9-9 25 0 34s25 9 34 0l31-31v102c0 13 11 24 24 24s24-11 24-24V244l31 31c9 9 25 9 34 0s9-25 0-34l-72-72c-10-9-25-9-34 0l-72 72z"
fill="#a6acb9" /> fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path <path
d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z" d="M281 169c9-9 25-9 34 0l72 72c9 9 9 25 0 34s-25 9-34 0l-31-31v102c0 13-11 24-24 24s-24-11-24-24V244l-31 31c-9 9-25 9-34 0s-9-25 0-34l72-72z"
fill="#1e3050" /> fill="var(--colors-primary-DEFAULT)" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468"> <svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
<path <path
d="M240-216c-88 0-160 72-160 160 0 5 0 10 1 15C33-18 0 31 0 88c0 80 65 144 144 144h304c71 0 128-57 128-128 0-50-28-93-70-114 4-12 6-25 6-38 0-66-54-120-120-120-11 0-23 2-33 5-30-33-72-53-119-53zM128-56c0-62 50-112 112-112 38 0 71 19 91 47 7 10 20 13 30 8 9-4 20-7 31-7 40 0 72 32 72 72 0 14-4 27-11 38-4 7-5 15-2 22s9 13 16 14c35 9 61 41 61 78 0 44-36 80-80 80H144c-53 0-96-43-96-96 0-43 28-79 67-91 11-4 18-16 16-29-2-7-3-16-3-24zm177 7c-9-9-25-9-34 0l-64 64c-9 9-9 25 0 34 10 9 25 9 34 0l23-23v86c0 13 11 24 24 24s24-11 24-24V26l23 23c9 9 25 9 34 0s9-25 0-34l-64-64z" d="M240-216c-88 0-160 72-160 160 0 5 0 10 1 15C33-18 0 31 0 88c0 80 65 144 144 144h304c71 0 128-57 128-128 0-50-28-93-70-114 4-12 6-25 6-38 0-66-54-120-120-120-11 0-23 2-33 5-30-33-72-53-119-53zM128-56c0-62 50-112 112-112 38 0 71 19 91 47 7 10 20 13 30 8 9-4 20-7 31-7 40 0 72 32 72 72 0 14-4 27-11 38-4 7-5 15-2 22s9 13 16 14c35 9 61 41 61 78 0 44-36 80-80 80H144c-53 0-96-43-96-96 0-43 28-79 67-91 11-4 18-16 16-29-2-7-3-16-3-24zm177 7c-9-9-25-9-34 0l-64 64c-9 9-9 25 0 34 10 9 25 9 34 0l23-23v86c0 13 11 24 24 24s24-11 24-24V26l23 23c9 9 25 9 34 0s9-25 0-34l-64-64z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>

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="currentColor"/></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="currentColor"/></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="currentColor"/></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="currentColor"/></svg>
</template>

View File

@@ -1,16 +1,16 @@
<template> <template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"> <svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
<path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z" <path d="M10 74v256c0 35 29 64 64 64h256c35 0 64-29 64-64V74c0-35-29-64-64-64H74c-35 0-64 29-64 64z"
fill="#a6acb9" /> fill="color-mix(in srgb, var(--colors-primary-DEFAULT) 40%, transparent)" />
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z" <path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
fill="#1e3050" /> fill="var(--colors-primary-DEFAULT)" />
</svg> </svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404"> <svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
<path <path
d="M96-136c-9 0-16 7-16 16v256c0 9 7 16 16 16h256c9 0 16-7 16-16v-256c0-9-7-16-16-16H96zm-64 16c0-35 29-64 64-64h256c35 0 64 29 64 64v256c0 35-29 64-64 64H96c-35 0-64-29-64-64v-256zm506-11c4-3 9-5 14-5 13 0 24 11 24 24v240c0 13-11 24-24 24-5 0-10-2-14-5l-74-55V32l64 48V-64l-64 48v-60l74-55z" d="M96-136c-9 0-16 7-16 16v256c0 9 7 16 16 16h256c9 0 16-7 16-16v-256c0-9-7-16-16-16H96zm-64 16c0-35 29-64 64-64h256c35 0 64 29 64 64v256c0 35-29 64-64 64H96c-35 0-64-29-64-64v-256zm506-11c4-3 9-5 14-5 13 0 24 11 24 24v240c0 13-11 24-24 24-5 0-10-2-14-5l-74-55V32l64 48V-64l-64 48v-60l74-55z"
fill="#1e3050" /> fill="currentColor" />
</svg> </svg>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
defineProps<{ filled?: boolean }>(); defineProps<{ filled?: boolean }>();
</script> </script>

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="currentColor"/></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="currentColor"/></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,61 @@
<script setup lang="ts">
import { computed } from 'vue';
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type UiButtonSize = 'sm' | 'md' | 'lg';
const props = withDefaults(
defineProps<{
variant?: UiButtonVariant;
size?: UiButtonSize;
block?: boolean;
disabled?: boolean;
loading?: boolean;
type?: 'button' | 'submit' | 'reset';
}>(),
{
variant: 'primary',
size: 'md',
block: false,
disabled: false,
loading: false,
type: 'button',
},
);
const isDisabled = computed(() => props.disabled || props.loading);
const classes = computed(() => {
const variants: Record<UiButtonVariant, string> = {
primary: 'border-transparent bg-primary text-white hover:bg-primaryHover focus-visible:ring-primary/25',
secondary: 'border-border bg-white text-text hover:bg-header focus-visible:ring-#0969da/20',
ghost: 'border-transparent bg-transparent text-text hover:bg-header focus-visible:ring-#0969da/20 shadow-none',
danger: 'border-transparent bg-danger text-white hover:opacity-92 focus-visible:ring-danger/20',
};
const sizes: Record<UiButtonSize, string> = {
sm: 'min-h-[28px] px-3 text-[12px] leading-[20px]',
md: 'min-h-[32px] px-3 text-[14px] leading-[20px]',
lg: 'min-h-[36px] px-4 text-[14px] leading-[20px]',
};
return [
'inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-primer outline-none transition-[transform,box-shadow,background-color,border-color,color,opacity] duration-150 ease-out active:translate-y-[0.5px] hover:shadow-[0_2px_0_rgba(27,31,36,0.06)] disabled:cursor-not-allowed disabled:opacity-60 focus-visible:ring-4',
variants[props.variant],
sizes[props.size],
props.block ? 'w-full' : '',
].join(' ');
});
</script>
<template>
<button :type="type" :disabled="isDisabled" :class="classes" :aria-busy="loading || undefined">
<span
v-if="loading"
class="h-4 w-4 shrink-0 animate-spin rounded-full border-2 border-current border-r-transparent"
aria-hidden="true"
/>
<slot v-else name="icon" />
<slot />
</button>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue';
import AppDialog from '@/components/ui/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 { useTranslation } from 'i18next-vue';
import { onBeforeUnmount, onMounted, ref, watch } from 'vue';
// Ensure client-side only rendering to avoid hydration mismatch
const isMounted = ref(false);
onMounted(() => {
isMounted.value = true;
});
const props = withDefaults(defineProps<{
visible: boolean;
title?: string;
closable?: boolean;
maxWidthClass?: string;
}>(), {
title: '',
closable: true,
maxWidthClass: 'max-w-lg',
});
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'close'): void;
}>();
const { 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-header 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-header 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,55 @@
<script setup lang="ts">
import { cn } from '@/lib/utils';
interface SwitchProps {
disabled?: boolean;
ariaLabel?: string;
class?: string; // Đổi từ className sang class cho chuẩn Vue
}
const props = withDefaults(defineProps<SwitchProps>(), {
disabled: false,
});
// Vue 3.4+ - Quản lý v-model cực gọn
const modelValue = defineModel<boolean>({ default: false });
const emit = defineEmits<{
(e: 'change', value: boolean): void;
}>();
const toggle = () => {
if (props.disabled) return;
modelValue.value = !modelValue.value;
emit('change', modelValue.value);
};
</script>
<template>
<button
type="button"
role="switch"
:aria-checked="modelValue"
:aria-label="ariaLabel"
:disabled="disabled"
@click="toggle"
:class="cn(
// Layout & Size
'relative inline-flex h-6 w-11 shrink-0 items-center rounded-full border-2 border-transparent transition-colors duration-200',
// Focus states (UnoCSS style)
'outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2',
// Status states
disabled ? 'op-50 cursor-not-allowed' : 'cursor-pointer',
modelValue ? 'bg-primary' : 'bg-gray-200 dark:bg-dark-300',
props.class
)"
>
<span
:class="cn(
// Toggle thumb
'pointer-events-none block h-5 w-5 rounded-full bg-white shadow-sm transition-transform duration-200',
modelValue ? 'translate-x-5' : 'translate-x-0'
)"
/>
</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

@@ -0,0 +1,75 @@
<script setup lang="ts">
import { onMounted, ref, watch } from 'vue';
interface SelectOption {
label: string;
value: string | number;
}
interface Props {
loadOptions: () => Promise<SelectOption[]>;
placeholder?: string;
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Please select...',
disabled: false,
});
const modelValue = defineModel<string | number>();
const options = ref<SelectOption[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const fetchData = async () => {
loading.value = true;
error.value = null;
try {
options.value = await props.loadOptions();
} catch {
error.value = 'Failed to load options';
} finally {
loading.value = false;
}
};
onMounted(fetchData);
watch(() => props.loadOptions, fetchData);
</script>
<template>
<div class="space-y-2">
<div class="relative w-full">
<select
v-model="modelValue"
:disabled="loading || disabled"
class="w-full appearance-none rounded-md border border-border bg-header px-3 py-2 pr-10 text-sm text-foreground outline-none transition-all focus:border-primary/50 focus:ring-2 focus:ring-primary/30 disabled:cursor-not-allowed disabled:opacity-60"
>
<option value="" disabled>{{ placeholder }}</option>
<option
v-for="opt in options"
:key="opt.value"
:value="opt.value"
>
{{ opt.label }}
</option>
</select>
<div class="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-foreground/40">
<div v-if="loading" class="h-4 w-4 animate-spin rounded-full border-2 border-current border-r-transparent" />
<div v-else class="i-carbon-chevron-down text-lg" />
</div>
</div>
<button
v-if="error"
type="button"
@click="fetchData"
class="text-xs font-medium text-danger transition hover:opacity-80"
>
{{ error }} · Retry
</button>
</div>
</template>

View File

@@ -0,0 +1,153 @@
<script setup lang="ts" generic="TData extends Record<string, any>">
import { cn } from '@/lib/utils';
import {
FlexRender,
getCoreRowModel,
getSortedRowModel,
useVueTable,
type ColumnMeta,
type ColumnDef,
type Row,
type SortingState,
type Updater,
} from '@tanstack/vue-table';
type TableColumnMeta = ColumnMeta<TData, any> & {
headerClass?: string;
cellClass?: string;
};
const props = withDefaults(defineProps<{
data: TData[];
columns: ColumnDef<TData, any>[];
loading?: boolean;
emptyText?: string;
tableClass?: string;
wrapperClass?: string;
headerRowClass?: string;
bodyRowClass?: string | ((row: Row<TData>) => string | undefined);
getRowId?: (originalRow: TData, index: number) => string;
}>(), {
loading: false,
emptyText: 'No data available.',
});
const sorting = ref<SortingState>([]);
function updateSorting(updaterOrValue: Updater<SortingState>) {
sorting.value = typeof updaterOrValue === 'function'
? updaterOrValue(sorting.value)
: updaterOrValue;
}
const table = useVueTable<TData>({
get data() {
return props.data;
},
get columns() {
return props.columns;
},
getRowId: props.getRowId,
state: {
get sorting() {
return sorting.value;
},
},
onSortingChange: updateSorting,
getCoreRowModel: getCoreRowModel(),
getSortedRowModel: getSortedRowModel(),
});
function resolveBodyRowClass(row: Row<TData>) {
return typeof props.bodyRowClass === 'function'
? props.bodyRowClass(row)
: props.bodyRowClass;
}
</script>
<template>
<div :class="cn('overflow-x-auto rounded-xl border border-gray-200 bg-white', wrapperClass)">
<table :class="cn('w-full min-w-[48rem] border-collapse', tableClass)">
<thead class="bg-header">
<tr
v-for="headerGroup in table.getHeaderGroups()"
:key="headerGroup.id"
:class="cn('border-b border-gray-200', headerRowClass)"
>
<th
v-for="header in headerGroup.headers"
:key="header.id"
:class="cn(
'px-4 py-3 text-left text-sm font-medium text-gray-600',
header.column.getCanSort() && !header.isPlaceholder && 'cursor-pointer select-none',
(header.column.columnDef.meta as TableColumnMeta | undefined)?.headerClass
)"
@click="header.column.getToggleSortingHandler()?.($event)"
>
<div class="flex items-center gap-2">
<FlexRender
v-if="!header.isPlaceholder"
:render="header.column.columnDef.header"
:props="header.getContext()"
/>
<span
v-if="header.column.getCanSort()"
class="text-[10px] uppercase tracking-wide text-gray-400"
>
{{ header.column.getIsSorted() === 'asc' ? 'asc' : header.column.getIsSorted() === 'desc' ? 'desc' : '' }}
</span>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td
:colspan="columns.length || 1"
class="px-4 py-10 text-center text-sm text-gray-500"
>
<slot name="loading">
Loading...
</slot>
</td>
</tr>
<tr v-else-if="!table.getRowModel().rows.length">
<td
:colspan="columns.length || 1"
class="px-4 py-10 text-center text-sm text-gray-500"
>
<slot name="empty">
{{ emptyText }}
</slot>
</td>
</tr>
<tr
v-for="row in table.getRowModel().rows"
v-else
:key="row.id"
:class="cn(
'border-b border-gray-200 transition-colors last:border-b-0 hover:bg-gray-50',
resolveBodyRowClass(row)
)"
>
<td
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="cn(
'px-4 py-3 align-middle',
(cell.column.columnDef.meta as TableColumnMeta | undefined)?.cellClass
)"
>
<FlexRender
:render="cell.column.columnDef.cell"
:props="cell.getContext()"
/>
</td>
</tr>
</tbody>
</table>
</div>
</template>

View File

@@ -0,0 +1,58 @@
import { TinyMqttClient } from "@/lib/liteMqtt";
import { useAuthStore } from "@/stores/auth";
import { computed, onBeforeUnmount, watch } from "vue";
type RuntimeMessage = {
topic: string;
payload: any;
};
const mqttBrokerUrl = "wss://mqtt-dashboard.com:8884/mqtt";
export function useAdminRuntimeMqtt(onMessage: (message: RuntimeMessage) => void) {
const auth = useAuthStore();
let client: TinyMqttClient | undefined;
const isAdmin = computed(() => auth.user?.role?.toUpperCase?.() === "ADMIN");
const connect = () => {
if (import.meta.env.SSR || !isAdmin.value) return;
disconnect();
client = new TinyMqttClient(
mqttBrokerUrl,
["picpic/events", "picpic/logs/#", "picpic/job/+"],
(topic, raw) => {
try {
onMessage({ topic, payload: JSON.parse(raw) });
} catch {
onMessage({ topic, payload: raw });
}
},
);
client.connect();
};
const disconnect = () => {
client?.disconnect();
client = undefined;
};
const stopWatch = watch(
() => [auth.user?.id, auth.user?.role],
() => {
if (isAdmin.value) {
connect();
} else {
disconnect();
}
},
{ immediate: true },
);
onBeforeUnmount(() => {
stopWatch();
disconnect();
});
return { disconnect };
}

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,47 @@
import { ref } from 'vue'
const isOffline = ref(false)
let listenersCount = 0
function syncNetworkStatus() {
if (typeof navigator === 'undefined') return
isOffline.value = !navigator.onLine
}
function handleNetworkStatusChange() {
syncNetworkStatus()
}
function startListening() {
if (typeof window === 'undefined') return
if (listenersCount === 0) {
syncNetworkStatus()
window.addEventListener('online', handleNetworkStatusChange)
window.addEventListener('offline', handleNetworkStatusChange)
}
listenersCount += 1
}
function stopListening() {
if (typeof window === 'undefined' || listenersCount === 0) return
listenersCount -= 1
if (listenersCount === 0) {
window.removeEventListener('online', handleNetworkStatusChange)
window.removeEventListener('offline', handleNetworkStatusChange)
}
}
export function useNetworkStatus() {
return {
isOffline,
syncNetworkStatus,
startListening,
stopListening,
}
}

View File

@@ -0,0 +1,126 @@
import { client as rpcClient } from '@/api/rpcclient';
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;
createdAt?: 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.createdAt),
read: Boolean(item.read),
actionUrl: item.actionUrl || undefined,
actionLabel: item.actionLabel || undefined,
createdAt: item.createdAt,
});
const fetchNotifications = async () => {
loading.value = true;
try {
const response = await rpcClient.listNotifications();
notifications.value = (response.notifications || []).map(mapNotification);
loaded.value = true;
return notifications.value;
} finally {
loading.value = false;
}
};
const markRead = async (id: string) => {
if (!id) return;
await rpcClient.markNotificationRead({ id });
const item = notifications.value.find(notification => notification.id === id);
if (item) item.read = true;
};
const deleteNotification = async (id: string) => {
if (!id) return;
await rpcClient.deleteNotification({ id });
notifications.value = notifications.value.filter(notification => notification.id !== id);
};
const markAllRead = async () => {
await rpcClient.markAllNotificationsRead();
notifications.value = notifications.value.map(item => ({ ...item, read: true }));
};
const clearAll = async () => {
await rpcClient.clearNotifications();
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,76 @@
import { client as rpcClient } from '@/api/rpcclient';
import type { Preferences } from '@/server/gen/proto/app/v1/common';
import type { UpdatePreferencesRequest } from '@/server/gen/proto/app/v1/account';
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;
language: string;
locale: string;
};
export type NotificationSettingsDraft = {
email: boolean;
push: boolean;
marketing: boolean;
telegram: boolean;
};
type PreferencesResponse = {
preferences?: Preferences;
};
const DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT: SettingsPreferencesSnapshot = {
emailNotifications: true,
pushNotifications: true,
marketingNotifications: false,
telegramNotifications: false,
language: 'en',
locale: 'en',
};
const normalizePreferencesSnapshot = (responseData: unknown): SettingsPreferencesSnapshot => {
const preferences = (responseData as PreferencesResponse | undefined)?.preferences;
return {
emailNotifications: preferences?.emailNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.emailNotifications,
pushNotifications: preferences?.pushNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.pushNotifications,
marketingNotifications: preferences?.marketingNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.marketingNotifications,
telegramNotifications: preferences?.telegramNotifications ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.telegramNotifications,
language: preferences?.language ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.language,
locale: preferences?.locale ?? DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT.locale,
};
};
export const createNotificationSettingsDraft = (
snapshot: SettingsPreferencesSnapshot = DEFAULT_SETTINGS_PREFERENCES_SNAPSHOT,
): NotificationSettingsDraft => ({
email: snapshot.emailNotifications,
push: snapshot.pushNotifications,
marketing: snapshot.marketingNotifications,
telegram: snapshot.telegramNotifications,
});
export const toNotificationPreferencesPayload = (
draft: NotificationSettingsDraft,
): UpdatePreferencesRequest => ({
emailNotifications: draft.email,
pushNotifications: draft.push,
marketingNotifications: draft.marketing,
telegramNotifications: draft.telegram,
});
export function useSettingsPreferencesQuery() {
return useQuery({
key: () => SETTINGS_PREFERENCES_QUERY_KEY,
query: async () => {
const response = await rpcClient.getPreferences();
return normalizePreferencesSnapshot(response);
},
});
}

View File

@@ -1,4 +1,5 @@
import { ref, computed } from 'vue'; import { client } from '@/api/rpcclient';
import { computed, ref } from 'vue';
export interface QueueItem { export interface QueueItem {
id: string; id: string;
@@ -12,60 +13,117 @@ export interface QueueItem {
thumbnail?: string; thumbnail?: string;
file?: File; // Keep reference to file for local uploads file?: File; // Keep reference to file for local uploads
url?: string; // Keep reference to url for remote uploads url?: string; // Keep reference to url for remote uploads
// Upload chunk tracking
activeChunks?: number;
uploadedUrls?: string[];
cancelled?: boolean;
} }
const items = ref<QueueItem[]>([]); const items = ref<QueueItem[]>([]);
// Upload limits
const MAX_ITEMS = 5;
// Chunk upload configuration
const CHUNK_SIZE = 90 * 1024 * 1024; // 90MB per chunk
const MAX_PARALLEL = 3;
const MAX_RETRY = 3;
// Track active XHRs per item id so we can abort them on cancel
const activeXhrs = new Map<string, Set<XMLHttpRequest>>();
const abortItem = (id: string) => {
const xhrs = activeXhrs.get(id);
if (xhrs) {
xhrs.forEach(xhr => xhr.abort());
activeXhrs.delete(id);
}
};
export function useUploadQueue() { export function useUploadQueue() {
const remainingSlots = computed(() => Math.max(0, MAX_ITEMS - items.value.length));
const addFiles = (files: FileList) => { 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), id: Math.random().toString(36).substring(2, 9),
name: file.name, name: file.name,
type: 'local', type: 'local',
status: 'pending', // Start as pending status: 'pending',
progress: 0, progress: 0,
uploaded: '0 MB', uploaded: '0 MB',
total: formatSize(file.size), total: formatSize(file.size),
speed: '0 MB/s', speed: '0 MB/s',
file: file, file: file,
thumbnail: undefined // We could generate a thumbnail here if needed thumbnail: undefined,
activeChunks: 0,
uploadedUrls: [],
cancelled: false
})); }));
items.value.push(...newItems); items.value.push(...newItems);
return { added: newItems.length, skipped: files.length - allowed.length, duplicates: duplicates.length };
}; };
const addRemoteUrls = (urls: string[]) => { const addRemoteUrls = (urls: string[]) => {
const 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), id: Math.random().toString(36).substring(2, 9),
name: url.split('/').pop() || 'Remote File', name: url.split('/').pop() || 'Remote File',
type: 'remote', 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, progress: 0,
uploaded: '0 MB', uploaded: '0 MB',
total: 'Unknown', total: 'Unknown',
speed: '0 MB/s', speed: '0 MB/s',
url: url url: url,
activeChunks: 0,
uploadedUrls: [],
cancelled: false
})); }));
// Override status to pending for consistency with user request
newItems.forEach(i => i.status = 'pending');
items.value.push(...newItems); items.value.push(...newItems);
return { added: newItems.length, skipped: urls.length - allowed.length, duplicates: duplicateCount };
}; };
const removeItem = (id: string) => { const removeItem = (id: string) => {
abortItem(id);
const item = items.value.find(i => i.id === id);
if (item) item.cancelled = true;
const index = items.value.findIndex(item => item.id === id); const index = items.value.findIndex(item => item.id === id);
if (index !== -1) { if (index !== -1) items.value.splice(index, 1);
items.value.splice(index, 1); };
const cancelItem = (id: string) => {
abortItem(id);
const item = items.value.find(i => i.id === id);
if (item) {
item.cancelled = true;
item.status = 'error';
item.activeChunks = 0;
item.speed = '0 MB/s';
} }
}; };
const startQueue = () => { const startQueue = () => {
items.value.forEach(item => { items.value.forEach(item => {
if (item.status === 'pending') { if (item.status === 'pending') {
if (item.type === 'local') { if (item.type === 'local') {
startMockUpload(item.id); startChunkUpload(item.id);
} else { } else {
startMockRemoteFetch(item.id); startMockRemoteFetch(item.id);
} }
@@ -73,57 +131,192 @@ export function useUploadQueue() {
}); });
}; };
// Mock Upload Logic // Real Chunk Upload Logic
const startMockUpload = (id: string) => { const startChunkUpload = async (id: string) => {
const item = items.value.find(i => i.id === id); const item = items.value.find(i => i.id === id);
if (!item) return; if (!item || !item.file) return;
item.status = 'uploading'; item.status = 'uploading';
let progress = 0; item.activeChunks = 0;
const totalSize = item.file ? item.file.size : 1024 * 1024 * 50; // Default 50MB if unknown item.uploadedUrls = [];
// Random speed between 1MB/s and 5MB/s const file = item.file;
const speedBytesPerStep = (1024 * 1024) + Math.random() * (1024 * 1024 * 4); const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
const progressMap = new Map<number, number>(); // chunk index -> uploaded bytes
const interval = setInterval(() => { const queue: number[] = Array.from({ length: totalChunks }, (_, i) => i);
if (progress >= 100) {
clearInterval(interval); const updateProgress = () => {
item.status = 'complete'; let totalUploaded = 0;
item.progress = 100; progressMap.forEach(value => {
item.uploaded = item.total; totalUploaded += value;
return; });
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 if (activePromises.length > 0) {
const increment = Math.random() * 5 + 1; // 1-6% increment await Promise.all(activePromises);
progress = Math.min(progress + increment, 100); await processQueue();
}
item.progress = Math.floor(progress); };
// Calculate uploaded size string
const currentBytes = (progress / 100) * totalSize;
item.uploaded = formatSize(currentBytes);
// Re-randomize speed for realism
const currentSpeed = (1024 * 1024) + Math.random() * (1024 * 1024 * 2);
item.speed = formatSize(currentSpeed) + '/s';
}, 500); try {
await processQueue();
if (!item.cancelled) {
item.status = 'processing';
await completeUpload(item);
}
} catch (error) {
item.status = 'error';
console.error('Upload failed:', error);
}
};
const uploadChunk = (
index: number,
file: File,
progressMap: Map<number, number>,
updateProgress: () => void,
item: QueueItem
): Promise<void> => {
return new Promise((resolve, reject) => {
let retry = 0;
const attempt = () => {
if (item.cancelled) return resolve();
const start = index * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, file.size);
const chunk = file.slice(start, end);
const formData = new FormData();
formData.append('file', chunk, file.name);
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://tmpfiles.org/api/v1/upload');
// Register this XHR so it can be aborted on cancel
if (!activeXhrs.has(item.id)) activeXhrs.set(item.id, new Set());
activeXhrs.get(item.id)!.add(xhr);
const unregister = () => activeXhrs.get(item.id)?.delete(xhr);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable) {
progressMap.set(index, e.loaded);
updateProgress();
}
};
xhr.onload = function () {
unregister();
if (item.cancelled) return resolve();
if (xhr.status === 200) {
try {
const res = JSON.parse(xhr.responseText);
if (res.status === 'success') {
progressMap.set(index, chunk.size);
if (item.uploadedUrls) {
item.uploadedUrls[index] = res.data.url;
}
updateProgress();
resolve();
return;
}
} catch {
handleError();
}
}
handleError();
};
xhr.onabort = () => {
unregister();
resolve(); // treat abort as graceful completion — processQueue will short-circuit via item.cancelled
};
xhr.onerror = () => {
unregister();
handleError();
};
function handleError() {
retry++;
if (retry <= MAX_RETRY) {
setTimeout(attempt, 2000);
} else {
item.status = 'error';
reject(new Error(`Failed to upload chunk ${index + 1}`));
}
}
xhr.send(formData);
};
attempt();
});
};
const completeUpload = async (item: QueueItem) => {
if (!item.file || !item.uploadedUrls) return;
try {
const data = await client.merge(item.file.name, item.uploadedUrls, item.file.size);
// 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
// })
// });
if (!data) {
throw new Error('No response from server');
}
item.status = 'complete';
item.progress = 100;
item.uploaded = item.total;
item.speed = '0 MB/s';
} catch (error) {
item.status = 'error';
console.error('Merge failed:', error);
}
}; };
// Mock Remote Fetch Logic // Mock Remote Fetch Logic
const startMockRemoteFetch = (id: string) => { const startMockRemoteFetch = (id: string) => {
const item = items.value.find(i => i.id === id); const item = items.value.find(i => i.id === id);
if (!item) return; if (!item) return;
item.status = 'fetching'; // Update status to fetching
// Remote fetch takes some time then completes item.status = 'fetching';
setTimeout(() => {
// Switch to uploading/processing phase if we wanted, or just finish setTimeout(() => {
item.status = 'complete'; item.status = 'complete';
item.progress = 100; item.progress = 100;
}, 3000 + Math.random() * 3000); }, 3000 + Math.random() * 3000);
}; };
@@ -150,15 +343,29 @@ export function useUploadQueue() {
const pendingCount = computed(() => { const pendingCount = computed(() => {
return items.value.filter(i => i.status === 'pending').length; return items.value.filter(i => i.status === 'pending').length;
}); });
function removeAll() {
items.value = [];
}
// watch(items, (newItems) => {
// // console.log(newItems);
// if (newItems.length === 0) return;
// if (newItems.filter(i => i.status === 'pending' || i.status === 'uploading').length === 0) {
// // startQueue();
// items.value = [];
// }
// }, { deep: true });
return { return {
items, items,
addFiles, addFiles,
addRemoteUrls, addRemoteUrls,
removeItem, removeItem,
cancelItem,
removeAll,
startQueue, startQueue,
totalSize, totalSize,
completeCount, completeCount,
pendingCount pendingCount,
remainingSlots,
maxItems: MAX_ITEMS,
}; };
} }

View File

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

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,19 @@
import { renderSSRHead } from '@unhead/vue/server';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { contextStorage } from 'hono/context-storage';
import { cors } from "hono/cors";
import { streamText } from 'hono/streaming';
import isMobile from 'is-mobile';
import { renderToWebStream } from 'vue/server-renderer';
import { buildBootstrapScript } from './lib/manifest';
import { 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/')) { import { setupMiddlewares } from './server/middlewares/setup';
return await next() import { registerAuthRoutes } from './server/routes/auth';
} import { registerRpcRoutes } from './server/routes/rpc';
const url = new URL(c.req.url) import { registerSSRRoutes } from './server/routes/ssr';
url.host = 'api.pipic.fun' import { registerWellKnownRoutes } from './server/routes/wellKnown';
url.protocol = 'https:' import { setupServices } from './server/services/grpcClient';
url.pathname = path.replace(/^\/r/, '') || '/' const app = new Hono();
url.port = '' // Global middlewares
// console.log("url", url.toString()) setupMiddlewares(app);
// console.log("c.req.raw", c.req.raw) setupServices(app);
const headers = new Headers(c.req.header()); // Routes
headers.delete("host"); registerWellKnownRoutes(app);
headers.delete("connection"); registerAuthRoutes(app);
registerRpcRoutes(app);
registerSSRRoutes(app);
return fetch(url.toString(), { export default app;
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 + "'/>");
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",
};
const ESCAPE_REGEX = /[&><\u2028\u2029]/g;
function htmlEscape(str: string): string {
return str.replace(ESCAPE_REGEX, (match) => ESCAPE_LOOKUP[match]);
}
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();

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