Compare commits
80 Commits
develop
...
c861a3ba7a
| Author | SHA1 | Date | |
|---|---|---|---|
| c861a3ba7a | |||
| 476f40a529 | |||
| dbc8b1a82a | |||
| ab9b4f229d | |||
| b435638774 | |||
| 8515498ade | |||
| 43702e8bf7 | |||
| cc3f62a6a1 | |||
| 15b69773f0 | |||
| a80fa755d4 | |||
| 6a1d8b1aee | |||
| 1f8fdad2da | |||
| 5350f421f9 | |||
| 698abcec22 | |||
| b60f65e4d1 | |||
| e854c68ad0 | |||
| b787cd161a | |||
| bd8b21955e | |||
| 87c99e64cd | |||
| baa8811e9e | |||
| fa88fe26b3 | |||
| 90d8409aa9 | |||
| b4bbacd9f1 | |||
| 8b85736903 | |||
| 3beabcfe7f | |||
| 35117b7be9 | |||
| e3587eff71 | |||
| 57903b80b6 | |||
| 5c0ca0e139 | |||
| 9276603a70 | |||
| dc06412f79 | |||
| edc1a33547 | |||
| 3c24da4af8 | |||
| 3491a0a08e | |||
| 6d04f1cbdc | |||
| bbe15d5f3e | |||
| dba9713d96 | |||
| e1ba24d1bf | |||
| 77ece5224d | |||
| 16caa9281b | |||
| cd9aab8979 | |||
| c6924afe5b | |||
| a5b4028bc8 | |||
| ff1d4902bc | |||
| 00bbe0f503 | |||
| d6183d208e | |||
| 1a3dc948a8 | |||
| 718554dee9 | |||
| 85af2da6ad | |||
| 66028d934a | |||
| 4d41d6540a | |||
| 1ee2130d88 | |||
| 27a765044d | |||
| c3a8e5b474 | |||
| cf9c488012 | |||
| 478c31defa | |||
| a9e5ea61f8 | |||
| 7a1f5d5ae0 | |||
| 6200ab7a1b | |||
| 4cc2cc0691 | |||
| fc86b3472e | |||
| 6c4015f8c4 | |||
| 820aa7a597 | |||
| b87d18576b | |||
| 58f2874102 | |||
| 8bdcbbf527 | |||
| 770c09b9b2 | |||
| ac74faadbe | |||
| 5ae0a15a30 | |||
| 476c0eb647 | |||
| 7d3d33ef7e | |||
| 55f467a10e | |||
| 1fe77f24dc | |||
| 21950753ab | |||
| c4244c1097 | |||
| f805bac0e6 | |||
| eed14fa0e5 | |||
| 9f521c76f4 | |||
| ae61ece0b0 | |||
| 02247f9018 |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal 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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
79
.deploy/stream.ui-production.yaml
Normal file
79
.deploy/stream.ui-production.yaml
Normal file
@@ -0,0 +1,79 @@
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: stream.ui-config
|
||||
namespace: stream-production
|
||||
labels:
|
||||
app: stream.ui
|
||||
data:
|
||||
STREAM_API_GRPC_ADDR: "stream.api-svc:9000"
|
||||
GOOGLE_AUTH_FINALIZE_PATH: "/auth/google/finalize"
|
||||
---
|
||||
kind: Service
|
||||
apiVersion: v1
|
||||
metadata:
|
||||
name: stream.ui-svc
|
||||
namespace: stream-production
|
||||
labels:
|
||||
app: stream.ui
|
||||
spec:
|
||||
selector:
|
||||
app: stream.ui
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
targetPort: 3000
|
||||
type: NodePort
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: stream.ui-dep
|
||||
namespace: stream-production
|
||||
labels:
|
||||
app: stream.ui
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: stream.ui
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: stream.ui
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: registry-production-secret
|
||||
containers:
|
||||
- name: stream.ui
|
||||
image: registry.awing.vn/stream-production/stream.ui:$BUILD_NUMBER
|
||||
ports:
|
||||
- containerPort: 3000
|
||||
env:
|
||||
- name: STREAM_API_GRPC_ADDR
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: stream.ui-config
|
||||
key: STREAM_API_GRPC_ADDR
|
||||
- name: GOOGLE_AUTH_FINALIZE_PATH
|
||||
valueFrom:
|
||||
configMapKeyRef:
|
||||
name: stream.ui-config
|
||||
key: GOOGLE_AUTH_FINALIZE_PATH
|
||||
- name: STREAM_INTERNAL_AUTH_MARKER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stream.ui-secret
|
||||
key: STREAM_INTERNAL_AUTH_MARKER
|
||||
- name: STREAM_UI_JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stream.ui-secret
|
||||
key: STREAM_UI_JWT_SECRET
|
||||
- name: STREAM_UI_REDIS_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: stream.ui-secret
|
||||
key: STREAM_UI_REDIS_URL
|
||||
|
||||
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
374
AGENTS.md
Normal 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
83
CLAUDE.md
Normal 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
39
Dockerfile
Normal 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" ]
|
||||
172
components.d.ts
vendored
172
components.d.ts
vendored
@@ -12,59 +12,187 @@ export {}
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
||||
Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
AppButton: typeof import('./src/components/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']
|
||||
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']
|
||||
BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
Button: typeof import('primevue/button')['default']
|
||||
Checkbox: typeof import('primevue/checkbox')['default']
|
||||
BellDot: typeof import('./src/components/icons/BellDot.vue')['default']
|
||||
BellOff: typeof import('./src/components/icons/BellOff.vue')['default']
|
||||
Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
||||
EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||
GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
Globe: typeof import('./src/components/icons/Globe.vue')['default']
|
||||
GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
|
||||
HardDrive: typeof import('./src/components/icons/hard-drive.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']
|
||||
HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
InputText: typeof import('primevue/inputtext')['default']
|
||||
ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
Inbox: typeof import('./src/components/icons/Inbox.vue')['default']
|
||||
InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
LanguageIcon: typeof import('./src/components/icons/LanguageIcon.vue')['default']
|
||||
Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
Message: typeof import('primevue/message')['default']
|
||||
Password: typeof import('primevue/password')['default']
|
||||
LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
ListIcon: typeof import('./src/components/icons/ListIcon.vue')['default']
|
||||
LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
MoneyCheck: typeof import('./src/components/icons/MoneyCheck.vue')['default']
|
||||
MonitorIcon: typeof import('./src/components/icons/MonitorIcon.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']
|
||||
PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['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']
|
||||
PopupAdsRuntime: typeof import('./src/components/PopupAdsRuntime.vue')['default']
|
||||
RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
|
||||
RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
ShieldUser: typeof import('./src/components/icons/shield-user.vue')['default']
|
||||
SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
UploadFilled: typeof import('./src/components/icons/UploadFilled.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']
|
||||
VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
|
||||
VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
|
||||
VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
|
||||
Windows: typeof import('./src/components/icons/windows.vue')['default']
|
||||
XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
||||
}
|
||||
}
|
||||
|
||||
// For TSX support
|
||||
declare global {
|
||||
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
|
||||
const Add: typeof import('./src/components/icons/Add.vue')['default']
|
||||
const AddFilled: typeof import('./src/components/icons/AddFilled.vue')['default']
|
||||
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
|
||||
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
|
||||
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
|
||||
const AppButton: typeof import('./src/components/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 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 BellFilled: typeof import('./src/components/icons/BellFilled.vue')['default']
|
||||
const Button: typeof import('primevue/button')['default']
|
||||
const Checkbox: typeof import('primevue/checkbox')['default']
|
||||
const BellDot: typeof import('./src/components/icons/BellDot.vue')['default']
|
||||
const BellOff: typeof import('./src/components/icons/BellOff.vue')['default']
|
||||
const Chart: typeof import('./src/components/icons/Chart.vue')['default']
|
||||
const CheckCircleIcon: typeof import('./src/components/icons/CheckCircleIcon.vue')['default']
|
||||
const CheckIcon: typeof import('./src/components/icons/CheckIcon.vue')['default']
|
||||
const CheckMarkIcon: typeof import('./src/components/icons/CheckMarkIcon.vue')['default']
|
||||
const ClientOnly: typeof import('./src/components/ClientOnly.tsx')['default']
|
||||
const CoinsIcon: typeof import('./src/components/icons/CoinsIcon.vue')['default']
|
||||
const copy: typeof import('./src/components/icons/UserIcon copy.vue')['default']
|
||||
const Credit: typeof import('./src/components/icons/Credit.vue')['default']
|
||||
const CreditCardIcon: typeof import('./src/components/icons/CreditCardIcon.vue')['default']
|
||||
const DashboardLayout: typeof import('./src/components/DashboardLayout.vue')['default']
|
||||
const DashboardNav: typeof import('./src/components/DashboardNav.vue')['default']
|
||||
const DownloadIcon: typeof import('./src/components/icons/DownloadIcon.vue')['default']
|
||||
const EllipsisVerticalIcon: typeof import('./src/components/icons/EllipsisVerticalIcon.vue')['default']
|
||||
const EmptyState: typeof import('./src/components/dashboard/EmptyState.vue')['default']
|
||||
const FileUploadType: typeof import('./src/components/icons/FileUploadType.vue')['default']
|
||||
const GlobalUploadIndicator: typeof import('./src/components/GlobalUploadIndicator.vue')['default']
|
||||
const Globe: typeof import('./src/components/icons/Globe.vue')['default']
|
||||
const GlobeIcon: typeof import('./src/components/icons/GlobeIcon.vue')['default']
|
||||
const HardDrive: typeof import('./src/components/icons/hard-drive.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 HomeFilled: typeof import('./src/components/icons/HomeFilled.vue')['default']
|
||||
const InputText: typeof import('primevue/inputtext')['default']
|
||||
const ImageIcon: typeof import('./src/components/icons/ImageIcon.vue')['default']
|
||||
const Inbox: typeof import('./src/components/icons/Inbox.vue')['default']
|
||||
const InfoIcon: typeof import('./src/components/icons/InfoIcon.vue')['default']
|
||||
const LanguageIcon: typeof import('./src/components/icons/LanguageIcon.vue')['default']
|
||||
const Layout: typeof import('./src/components/icons/Layout.vue')['default']
|
||||
const LayoutFilled: typeof import('./src/components/icons/LayoutFilled.vue')['default']
|
||||
const Message: typeof import('primevue/message')['default']
|
||||
const Password: typeof import('primevue/password')['default']
|
||||
const LayoutDashboard: typeof import('./src/components/icons/LayoutDashboard.vue')['default']
|
||||
const LinkIcon: typeof import('./src/components/icons/LinkIcon.vue')['default']
|
||||
const ListIcon: typeof import('./src/components/icons/ListIcon.vue')['default']
|
||||
const LockIcon: typeof import('./src/components/icons/LockIcon.vue')['default']
|
||||
const MailIcon: typeof import('./src/components/icons/MailIcon.vue')['default']
|
||||
const MoneyCheck: typeof import('./src/components/icons/MoneyCheck.vue')['default']
|
||||
const MonitorIcon: typeof import('./src/components/icons/MonitorIcon.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 PanelLeft: typeof import('./src/components/icons/PanelLeft.vue')['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 PopupAdsRuntime: typeof import('./src/components/PopupAdsRuntime.vue')['default']
|
||||
const RepeatIcon: typeof import('./src/components/icons/RepeatIcon.vue')['default']
|
||||
const RootLayout: typeof import('./src/components/RootLayout.vue')['default']
|
||||
const RouterLink: typeof import('vue-router')['RouterLink']
|
||||
const RouterView: typeof import('vue-router')['RouterView']
|
||||
const SendIcon: typeof import('./src/components/icons/SendIcon.vue')['default']
|
||||
const SettingsIcon: typeof import('./src/components/icons/SettingsIcon.vue')['default']
|
||||
const ShieldUser: typeof import('./src/components/icons/shield-user.vue')['default']
|
||||
const SlidersIcon: typeof import('./src/components/icons/SlidersIcon.vue')['default']
|
||||
const StatsCard: typeof import('./src/components/dashboard/StatsCard.vue')['default']
|
||||
const TelegramIcon: typeof import('./src/components/icons/TelegramIcon.vue')['default']
|
||||
const TestIcon: typeof import('./src/components/icons/TestIcon.vue')['default']
|
||||
const TrashIcon: typeof import('./src/components/icons/TrashIcon.vue')['default']
|
||||
const Upload: typeof import('./src/components/icons/Upload.vue')['default']
|
||||
const UploadFilled: typeof import('./src/components/icons/UploadFilled.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 VideoFilled: typeof import('./src/components/icons/VideoFilled.vue')['default']
|
||||
const VideoIcon: typeof import('./src/components/icons/VideoIcon.vue')['default']
|
||||
const VideoPlayIcon: typeof import('./src/components/icons/VideoPlayIcon.vue')['default']
|
||||
const VolumeIcon: typeof import('./src/components/icons/VolumeIcon.vue')['default']
|
||||
const VolumeOffIcon: typeof import('./src/components/icons/VolumeOffIcon.vue')['default']
|
||||
const VueHead: typeof import('./src/components/VueHead.tsx')['default']
|
||||
const WifiIcon: typeof import('./src/components/icons/WifiIcon.vue')['default']
|
||||
const Windows: typeof import('./src/components/icons/windows.vue')['default']
|
||||
const XCircleIcon: typeof import('./src/components/icons/XCircleIcon.vue')['default']
|
||||
const XIcon: typeof import('./src/components/icons/XIcon.vue')['default']
|
||||
}
|
||||
64
package.json
64
package.json
@@ -2,43 +2,47 @@
|
||||
"name": "holistream",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"deploy": "wrangler deploy",
|
||||
"cf-typegen": "wrangler types --env-interface CloudflareBindings"
|
||||
"dev": "bun x --bun vite",
|
||||
"build": "bun x --bun vite build",
|
||||
"preview": "bun x --bun vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.946.0",
|
||||
"@aws-sdk/s3-presigned-post": "^3.946.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.946.0",
|
||||
"@bufbuild/protobuf": "^2.11.0",
|
||||
"@grpc/grpc-js": "^1.14.3",
|
||||
"@hattip/adapter-node": "^0.0.49",
|
||||
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
|
||||
"@hiogawa/utils": "^1.7.0",
|
||||
"@primeuix/themes": "^2.0.2",
|
||||
"@primevue/forms": "^4.5.4",
|
||||
"@unhead/vue": "^2.1.1",
|
||||
"is-mobile": "^5.0.0",
|
||||
"@vueuse/core": "^14.1.0",
|
||||
"@hono/node-server": "^1.19.12",
|
||||
"@hono/zod-validator": "^0.7.6",
|
||||
"@pinia/colada": "^1.1.0",
|
||||
"@tanstack/vue-table": "^8.21.3",
|
||||
"@unhead/vue": "^2.1.12",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"aws4fetch": "^1.0.20",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"hono": "^4.11.3",
|
||||
"hono": "^4.12.9",
|
||||
"i18next": "^26.0.3",
|
||||
"i18next-http-backend": "^3.0.4",
|
||||
"i18next-vue": "^5.4.0",
|
||||
"is-mobile": "^5.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"primevue": "^4.5.4",
|
||||
"vue": "^3.5.26",
|
||||
"vue-router": "^4.6.4",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.3.2"
|
||||
"superjson": "^2.2.6",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tweetnacl": "^1.0.3",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^5.0.4",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/vite-plugin": "^1.17.1",
|
||||
"@primevue/auto-import-resolver": "^4.5.4",
|
||||
"@types/node": "^25.0.3",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.3",
|
||||
"unocss": "^66.5.12",
|
||||
"unplugin-auto-import": "^20.3.0",
|
||||
"unplugin-vue-components": "^30.0.0",
|
||||
"vite": "^7.3.0",
|
||||
"vite-ssr-components": "^0.5.2",
|
||||
"wrangler": "^4.54.0"
|
||||
"@types/bun": "^1.3.11",
|
||||
"@vitejs/plugin-vue": "^6.0.5",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.5",
|
||||
"estree-walker": "3.0.3",
|
||||
"unocss": "^66.6.7",
|
||||
"unplugin-auto-import": "^21.0.0",
|
||||
"unplugin-vue-components": "^32.0.0",
|
||||
"vite": "^8.0.3",
|
||||
"vite-ssr-components": "^0.5.2"
|
||||
}
|
||||
}
|
||||
1355
public/locales/en/translation.json
Normal file
1355
public/locales/en/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
1350
public/locales/vi/translation.json
Normal file
1350
public/locales/vi/translation.json
Normal file
File diff suppressed because it is too large
Load Diff
7
scripts/gen-nacl-keys.ts
Normal file
7
scripts/gen-nacl-keys.ts
Normal 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"));
|
||||
@@ -6,20 +6,37 @@ 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 payload = JSON.stringify(data.args);
|
||||
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 })
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||
{
|
||||
headers: extraHeaders
|
||||
}
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
@@ -27,11 +44,13 @@ export function httpClientAdapter(opts: {
|
||||
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}`);
|
||||
@@ -45,8 +64,10 @@ export function httpClientAdapter(opts: {
|
||||
);
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text()
|
||||
await res.text(),
|
||||
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
|
||||
@@ -3,15 +3,28 @@ import { Result } from "@hiogawa/utils";
|
||||
import { tryGetContext } from "hono/context-storage";
|
||||
|
||||
const GET_PAYLOAD_PARAM = "payload";
|
||||
export const baseAPIURL = "https://api.pipic.fun";
|
||||
|
||||
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 payload = JSON.stringify(data.args);
|
||||
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";
|
||||
@@ -20,7 +33,10 @@ export function httpClientAdapter(opts: {
|
||||
req = new Request(
|
||||
url +
|
||||
"?" +
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
|
||||
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
|
||||
{
|
||||
headers: extraHeaders
|
||||
}
|
||||
);
|
||||
} else {
|
||||
req = new Request(url, {
|
||||
@@ -28,6 +44,7 @@ export function httpClientAdapter(opts: {
|
||||
body: payload,
|
||||
headers: {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
...extraHeaders,
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
@@ -45,6 +62,7 @@ export function httpClientAdapter(opts: {
|
||||
} else {
|
||||
res = await fetch(req);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
// throw new Error(`HTTP error: ${res.status}`);
|
||||
throw new Error(
|
||||
@@ -58,7 +76,8 @@ export function httpClientAdapter(opts: {
|
||||
// throw TinyRpcError.deserialize(res.status);
|
||||
}
|
||||
const result: Result<unknown, unknown> = JSON.parse(
|
||||
await res.text()
|
||||
await res.text(),
|
||||
() => Object.fromEntries((res.headers as any).entries() ?? [])
|
||||
);
|
||||
if (!result.ok) {
|
||||
throw TinyRpcError.deserialize(result.value);
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { getContext } from "hono/context-storage";
|
||||
import { setCookie, deleteCookie, getCookie } from 'hono/cookie';
|
||||
import { HonoVarTypes } from "types";
|
||||
import { sign, verify } from "hono/jwt";
|
||||
|
||||
interface RegisterModel {
|
||||
username: string;
|
||||
password: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// Mock user database (in-memory)
|
||||
const mockUsers: Map<string, { password: string; user: User }> = new Map([
|
||||
['admin', {
|
||||
password: 'admin123',
|
||||
user: {
|
||||
id: '1',
|
||||
username: 'admin',
|
||||
email: 'admin@example.com',
|
||||
name: 'Admin User'
|
||||
}
|
||||
}],
|
||||
['user@example.com', {
|
||||
password: 'password',
|
||||
user: {
|
||||
id: '2',
|
||||
username: 'user',
|
||||
email: 'user@example.com',
|
||||
name: 'Test User'
|
||||
}
|
||||
}]
|
||||
]);
|
||||
|
||||
// CSRF token storage (in-memory, in production use Redis or similar)
|
||||
const csrfTokens = new Map<string, { token: string; expires: number }>();
|
||||
|
||||
// Secret for JWT signing
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';
|
||||
|
||||
function generateCSRFToken(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
||||
function validateCSRFToken(sessionId: string, token: string): boolean {
|
||||
const stored = csrfTokens.get(sessionId);
|
||||
if (!stored) return false;
|
||||
if (stored.expires < Date.now()) {
|
||||
csrfTokens.delete(sessionId);
|
||||
return false;
|
||||
}
|
||||
return stored.token === token;
|
||||
}
|
||||
|
||||
const register = async (registerModel: RegisterModel) => {
|
||||
// Check if user already exists
|
||||
if (mockUsers.has(registerModel.username) || mockUsers.has(registerModel.email)) {
|
||||
throw new Error('User already exists');
|
||||
}
|
||||
|
||||
const newUser: User = {
|
||||
id: crypto.randomUUID(),
|
||||
username: registerModel.username,
|
||||
email: registerModel.email,
|
||||
name: registerModel.username
|
||||
};
|
||||
|
||||
mockUsers.set(registerModel.username, {
|
||||
password: registerModel.password,
|
||||
user: newUser
|
||||
});
|
||||
|
||||
mockUsers.set(registerModel.email, {
|
||||
password: registerModel.password,
|
||||
user: newUser
|
||||
});
|
||||
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const sessionId = crypto.randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
|
||||
// Store CSRF token (expires in 1 hour)
|
||||
csrfTokens.set(sessionId, {
|
||||
token: csrfToken,
|
||||
expires: Date.now() + 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// Create JWT token with user info
|
||||
const token = await sign({
|
||||
sub: newUser.id,
|
||||
username: newUser.username,
|
||||
email: newUser.email,
|
||||
sessionId,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
|
||||
}, JWT_SECRET);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
setCookie(context, 'auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 // 24 hours
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: newUser,
|
||||
csrfToken // Return CSRF token to client for subsequent requests
|
||||
};
|
||||
};
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
// Try to find user by username or email
|
||||
const userRecord = mockUsers.get(username);
|
||||
|
||||
if (!userRecord) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
if (userRecord.password !== password) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const sessionId = crypto.randomUUID();
|
||||
const csrfToken = generateCSRFToken();
|
||||
|
||||
// Store CSRF token (expires in 1 hour)
|
||||
csrfTokens.set(sessionId, {
|
||||
token: csrfToken,
|
||||
expires: Date.now() + 60 * 60 * 1000
|
||||
});
|
||||
|
||||
// Create JWT token with user info
|
||||
const token = await sign({
|
||||
sub: userRecord.user.id,
|
||||
username: userRecord.user.username,
|
||||
email: userRecord.user.email,
|
||||
sessionId,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24 // 24 hours
|
||||
}, JWT_SECRET);
|
||||
|
||||
// Set HTTP-only cookie
|
||||
setCookie(context, 'auth_token', token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'Lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 // 24 hours
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: userRecord.user,
|
||||
csrfToken // Return CSRF token to client for subsequent requests
|
||||
};
|
||||
};
|
||||
|
||||
async function checkAuth() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (!token) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
|
||||
// Find user
|
||||
const userRecord = Array.from(mockUsers.values()).find(
|
||||
record => record.user.id === payload.sub
|
||||
);
|
||||
|
||||
if (!userRecord) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user: userRecord.user
|
||||
};
|
||||
} catch (error) {
|
||||
return { authenticated: false, user: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (token) {
|
||||
try {
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
// Remove CSRF token
|
||||
if (payload.sessionId) {
|
||||
csrfTokens.delete(payload.sessionId);
|
||||
}
|
||||
} catch (error) {
|
||||
// Token invalid, just delete cookie
|
||||
}
|
||||
}
|
||||
|
||||
deleteCookie(context, 'auth_token', { path: '/' });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function getCSRFToken() {
|
||||
const context = getContext<HonoVarTypes>();
|
||||
const token = getCookie(context, 'auth_token');
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
|
||||
const payload = await verify(token, JWT_SECRET) as any;
|
||||
const stored = csrfTokens.get(payload.sessionId);
|
||||
|
||||
// if (!stored) {
|
||||
// throw new Error('CSRF token not found');
|
||||
// }
|
||||
|
||||
return { csrfToken: stored?.token || null };
|
||||
}
|
||||
|
||||
export const authMethods = {
|
||||
register,
|
||||
login,
|
||||
checkAuth,
|
||||
logout,
|
||||
getCSRFToken,
|
||||
};
|
||||
|
||||
export { validateCSRFToken };
|
||||
@@ -1 +0,0 @@
|
||||
export const secret = "123_it-is-very-secret_123";
|
||||
@@ -1,335 +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 { csrf } from 'hono/csrf'
|
||||
import { z } from "zod";
|
||||
import { authMethods } from "./auth";
|
||||
import { jwt } from "hono/jwt";
|
||||
import { secret } from "./commom";
|
||||
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 jwtRpc: MiddlewareHandler = async (c, next) => {
|
||||
const publicPaths: (keyof typeof routes)[] = ["getHomeCourses", "getCourses", "getCourseBySlug", "getCourseContent", "login", "register"];
|
||||
const isPublic = publicPaths.some((path) => c.req.path.split("/").includes(path));
|
||||
c.set("isPublic", isPublic);
|
||||
// return await next();
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/") || isPublic) {
|
||||
return await next();
|
||||
}
|
||||
console.log("JWT RPC Middleware:", c.req.path);
|
||||
const jwtMiddleware = jwt({
|
||||
secret,
|
||||
cookie: 'auth_token',
|
||||
verification: {
|
||||
aud: "ez.lms_users",
|
||||
}
|
||||
})
|
||||
return jwtMiddleware(c, next)
|
||||
}
|
||||
export const rpcServer = async (c: Context, next: Next) => {
|
||||
if (c.req.path !== endpoint && !c.req.path.startsWith(endpoint + "/")) {
|
||||
return await next();
|
||||
}
|
||||
const cert = c.req.header()
|
||||
console.log("RPC Request Path:", c.req.raw.cf);
|
||||
// if (!cert) return c.text('Forbidden', 403)
|
||||
const handler = exposeTinyRpc({
|
||||
routes,
|
||||
adapter: httpServerAdapter({ endpoint }),
|
||||
});
|
||||
const res = await handler({ request: c.req.raw });
|
||||
if (res) {
|
||||
return res;
|
||||
}
|
||||
return await next();
|
||||
};
|
||||
@@ -1,198 +0,0 @@
|
||||
import {
|
||||
S3Client,
|
||||
ListBucketsCommand,
|
||||
ListObjectsV2Command,
|
||||
GetObjectCommand,
|
||||
PutObjectCommand,
|
||||
DeleteObjectCommand,
|
||||
CreateMultipartUploadCommand,
|
||||
UploadPartCommand,
|
||||
AbortMultipartUploadCommand,
|
||||
CompleteMultipartUploadCommand,
|
||||
ListPartsCommand,
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import { createPresignedPost } from "@aws-sdk/s3-presigned-post";
|
||||
import { randomBytes } from "crypto";
|
||||
const urlAlphabet = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict';
|
||||
|
||||
export function nanoid(size = 21) {
|
||||
let id = '';
|
||||
const bytes = randomBytes(size); // Node.js specific method
|
||||
|
||||
for (let i = 0; i < size; i++) {
|
||||
id += urlAlphabet[bytes[i] & 63];
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
// createPresignedPost
|
||||
const S3 = new S3Client({
|
||||
region: "auto", // Required by SDK but not used by R2
|
||||
endpoint: `https://s3.cloudfly.vn`,
|
||||
credentials: {
|
||||
// accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
accessKeyId: "BD707P5W8J5DHFPUKYZ6",
|
||||
secretAccessKey: "LTX7IizSDn28XGeQaHNID2fOtagfLc6L2henrP6P",
|
||||
},
|
||||
forcePathStyle: true,
|
||||
});
|
||||
// const S3 = new S3Client({
|
||||
// region: "auto", // Required by SDK but not used by R2
|
||||
// endpoint: `https://u.pipic.fun`,
|
||||
// credentials: {
|
||||
// // accessKeyId: "Q3AM3UQ867SPQQA43P2F",
|
||||
// // secretAccessKey: "Ik7nlCaUUCFOKDJAeSgFcbF5MEBGh9sVGBUrsUOp",
|
||||
// accessKeyId: "cdnadmin",
|
||||
// secretAccessKey: "D@tkhong9",
|
||||
// },
|
||||
// forcePathStyle: true,
|
||||
// });
|
||||
export const imageContentTypes = ["image/png", "image/jpg", "image/jpeg", "image/webp"];
|
||||
export const videoContentTypes = ["video/mp4", "video/webm", "video/ogg", "video/*"];
|
||||
const nanoId = () => {
|
||||
// return crypto.randomUUID().replace(/-/g, "").slice(0, 10);
|
||||
return ""
|
||||
}
|
||||
export async function presignedPut(fileName: string, contentType: string){
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
const key = nanoId()+"_"+fileName;
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new PutObjectCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
// ContentLength: 31457280, // Max 30MB
|
||||
// ACL: "public-read", // Uncomment if you want the object to be publicly readable
|
||||
}),
|
||||
{ expiresIn: 600 } // URL valid for 10 minutes
|
||||
);
|
||||
return { url, key };
|
||||
}
|
||||
export async function createPresignedUrls({
|
||||
key,
|
||||
uploadId,
|
||||
totalParts,
|
||||
expiresIn = 60 * 15, // 15 phút
|
||||
}: {
|
||||
key: string;
|
||||
uploadId: string;
|
||||
totalParts: number;
|
||||
expiresIn?: number;
|
||||
}) {
|
||||
const urls = [];
|
||||
|
||||
for (let partNumber = 1; partNumber <= totalParts; partNumber++) {
|
||||
const command = new UploadPartCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
PartNumber: partNumber,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(S3, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
urls.push({
|
||||
partNumber,
|
||||
url,
|
||||
});
|
||||
}
|
||||
|
||||
return urls;
|
||||
}
|
||||
export async function chunkedUpload(Key: string, contentType: string, fileSize: number) {
|
||||
// lớn hơn 3gb thì cút
|
||||
if (fileSize > 3 * 1024 * 1024 * 1024) {
|
||||
throw new Error("File size exceeds 3GB");
|
||||
}
|
||||
// CreateMultipartUploadCommand
|
||||
const uploadParams = {
|
||||
Bucket: "tmp",
|
||||
Key,
|
||||
ContentType: contentType,
|
||||
CacheControl: "public, max-age=31536000, immutable",
|
||||
};
|
||||
let data = await S3.send(new CreateMultipartUploadCommand(uploadParams));
|
||||
return data;
|
||||
}
|
||||
export async function abortChunk(key: string, uploadId: string) {
|
||||
await S3.send(
|
||||
new AbortMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function completeChunk(key: string, uploadId: string, parts: { ETag: string; PartNumber: number }[]) {
|
||||
const listed = await S3.send(
|
||||
new ListPartsCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
})
|
||||
);
|
||||
if (!listed.Parts || listed.Parts.length !== parts.length) {
|
||||
throw new Error("Not all parts have been uploaded");
|
||||
}
|
||||
await S3.send(
|
||||
new CompleteMultipartUploadCommand({
|
||||
Bucket: "tmp",
|
||||
Key: key,
|
||||
UploadId: uploadId,
|
||||
MultipartUpload: {
|
||||
Parts: parts.sort((a, b) => a.PartNumber - b.PartNumber),
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function deleteObject(bucketName: string, objectKey: string) {
|
||||
await S3.send(
|
||||
new DeleteObjectCommand({
|
||||
Bucket: bucketName,
|
||||
Key: objectKey,
|
||||
})
|
||||
);
|
||||
}
|
||||
export async function listBuckets() {
|
||||
const data = await S3.send(new ListBucketsCommand({}));
|
||||
return data.Buckets;
|
||||
}
|
||||
export async function listObjects(bucketName: string) {
|
||||
const data = await S3.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: bucketName,
|
||||
})
|
||||
);
|
||||
return data.Contents;
|
||||
}
|
||||
export async function generateUploadForm(fileName: string, contentType: string) {
|
||||
if (!imageContentTypes.includes(contentType)) {
|
||||
throw new Error("Invalid content type");
|
||||
}
|
||||
return await createPresignedPost(S3, {
|
||||
Bucket: "tmp",
|
||||
Key: nanoId()+"_"+fileName,
|
||||
Expires: 10 * 60, // URL valid for 10 minutes
|
||||
Conditions: [
|
||||
["starts-with", "$Content-Type", contentType],
|
||||
["content-length-range", 0, 31457280], // Max 30MB
|
||||
],
|
||||
});
|
||||
}
|
||||
// generateUploadUrl("tmp", "cat.png", "image/png").then(console.log);
|
||||
export async function createDownloadUrl(key: string): Promise<string> {
|
||||
const url = await getSignedUrl(
|
||||
S3,
|
||||
new GetObjectCommand({ Bucket: "tmp", Key: key }),
|
||||
{ expiresIn: 600 } // 600 giây = 10 phút
|
||||
);
|
||||
return url;
|
||||
}
|
||||
@@ -1,19 +1,39 @@
|
||||
import {
|
||||
proxyTinyRpc,
|
||||
TinyRpcClientAdapter,
|
||||
TinyRpcError,
|
||||
} from "@hiogawa/tiny-rpc";
|
||||
import type { RpcRoutes } from "./rpc";
|
||||
import { Result } from "@hiogawa/utils";
|
||||
import {httpClientAdapter} from "@httpClientAdapter";
|
||||
// console.log("httpClientAdapter module:", httpClientAdapter.toString());
|
||||
declare let __host__: string;
|
||||
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 headers: Record<string, string> = {}; // inject headers to demonstrate context
|
||||
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: httpClientAdapter({
|
||||
url: url + endpoint,
|
||||
pathsForGET: [],
|
||||
}),
|
||||
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);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { createApp } from './main';
|
||||
import { hydrateQueryCache } from '@pinia/colada';
|
||||
import 'uno.css';
|
||||
import PiniaSharedState from './lib/PiniaSharedState';
|
||||
import { createApp } from './main';
|
||||
|
||||
const readAppData = () => {
|
||||
return JSON.parse(document.getElementById('__APP_DATA__')?.innerText || '{}') as Record<string, any>;
|
||||
};
|
||||
|
||||
async function render() {
|
||||
const { app, router } = createApp();
|
||||
router.isReady().then(() => {
|
||||
app.mount('body', true)
|
||||
})
|
||||
const appData = readAppData();
|
||||
const { app, router, queryCache, pinia } = await createApp(appData.$locale);
|
||||
pinia.use(PiniaSharedState({ enable: true, initialize: true }));
|
||||
hydrateQueryCache(queryCache, appData.$colada || {});
|
||||
|
||||
await router.isReady();
|
||||
app.mount('body', true);
|
||||
}
|
||||
|
||||
render().catch((error) => {
|
||||
console.error('Error during app initialization:', error)
|
||||
})
|
||||
console.error('Error during app initialization:', error);
|
||||
});
|
||||
|
||||
23
src/components/AppTopLoadingBar.vue
Normal file
23
src/components/AppTopLoadingBar.vue
Normal 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>
|
||||
25
src/components/ClientOnly.tsx
Normal file
25
src/components/ClientOnly.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
// export default defineComponent((props, context) => {
|
||||
// if (typeof window === 'undefined') {
|
||||
// return () => context.slots.default ? context.slots.default() : null;
|
||||
// }
|
||||
// return () => null;
|
||||
// });
|
||||
|
||||
import { defineComponent, onMounted, ref } from "vue";
|
||||
const ClientOnly = defineComponent({
|
||||
name: "ClientOnly",
|
||||
setup(_p, { slots }) {
|
||||
const isClient = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
isClient.value = true;
|
||||
});
|
||||
return () => {
|
||||
if (isClient.value) {
|
||||
return slots.default ? slots.default() : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
},
|
||||
});
|
||||
export default ClientOnly;
|
||||
@@ -1,44 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import Add from "@/components/icons/Add.vue";
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import Credit from "@/components/icons/Credit.vue";
|
||||
import Upload from "./icons/Upload.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { createStaticVNode } from "vue";
|
||||
import Upload from "@/routes/upload/Upload.vue";
|
||||
import DashboardNav from "./DashboardNav.vue";
|
||||
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
|
||||
import PopupAdsRuntime from "./PopupAdsRuntime.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const links = [
|
||||
{ href: "/fdsfsd", label: "app", icon: homeHoist, type: "btn" },
|
||||
{ href: "/", label: "Home", icon: Home, type: "a" },
|
||||
{ href: "/upload", label: "Upload", icon: Upload, type: "a" },
|
||||
{ href: "/video", label: "Video", icon: Video, type: "a" },
|
||||
{ href: "/plans", label: "Plans", icon: Credit, type: "a" },
|
||||
{ href: "/notification", label: "Notification", icon: Bell, type: "a" },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 w-18 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen border-r border-gray-200 bg-white">
|
||||
<component :is="i.type === 'a' ? 'router-link' : 'div'" v-for="i in links" :key="i.label"
|
||||
v-bind="i.type === 'a' ? { to: i.href } : {}" v-tooltip="i.label"
|
||||
:class="cn(className, $route.path === i.href && 'bg-primary/15')">
|
||||
<component :is="i.icon" :filled="$route.path === i.href" />
|
||||
</component>
|
||||
<div class="w-12 h-12 rounded-2xl hover:bg-primary/15 flex">
|
||||
<button class="h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
|
||||
<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" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<main class="flex flex-1 overflow-hidden md:ps-18">
|
||||
<div class="flex-1 overflow-auto p-4 bg-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<DashboardNav />
|
||||
<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-white rounded-lg md:(mr-2 mb-2) min-h-[calc(100vh-8rem)]">
|
||||
<router-view v-slot="{ Component }">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-in-out"
|
||||
enter-from-class="opacity-0 transform translate-y-4"
|
||||
@@ -50,5 +21,8 @@ const links = [
|
||||
</Transition>
|
||||
</router-view>
|
||||
</div>
|
||||
<GlobalUploadIndicator />
|
||||
<Upload />
|
||||
<PopupAdsRuntime />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
86
src/components/DashboardNav.vue
Normal file
86
src/components/DashboardNav.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script lang="ts" setup>
|
||||
import Bell from "@/components/icons/Bell.vue";
|
||||
import Home from "@/components/icons/Home.vue";
|
||||
import SettingsIcon from "@/components/icons/SettingsIcon.vue";
|
||||
import Video from "@/components/icons/Video.vue";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useNotifications } from "@/composables/useNotifications";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useTranslation } from "i18next-vue";
|
||||
import { computed, createStaticVNode, h, ref } from "vue";
|
||||
import NotificationDrawer from "./NotificationDrawer.vue";
|
||||
import Chart from "./icons/Chart.vue";
|
||||
|
||||
const className = ":uno: w-12 h-12 p-2 rounded-2xl hover:bg-primary/15 flex press-animated items-center justify-center shrink-0";
|
||||
const homeHoist = createStaticVNode(`<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />`, 1);
|
||||
const notificationPopover = ref<InstanceType<typeof NotificationDrawer>>();
|
||||
const isNotificationOpen = ref(false);
|
||||
const { t } = useTranslation();
|
||||
const notificationStore = useNotifications();
|
||||
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
||||
|
||||
const handleNotificationClick = (event: Event) => {
|
||||
notificationPopover.value?.toggle(event);
|
||||
};
|
||||
|
||||
const links = computed<Record<string, any>>(() => {
|
||||
const baseLinks = [
|
||||
{
|
||||
id: "home",
|
||||
href: "/#home", label: "app", icon: homeHoist, action: () => { }, className
|
||||
},
|
||||
{
|
||||
id: "overview",
|
||||
href: "/", label: t("nav.overview"), icon: Home, action: null, className
|
||||
},
|
||||
{
|
||||
id: "videos",
|
||||
href: "/videos", label: t("nav.videos"), icon: Video, action: null, className
|
||||
},
|
||||
{
|
||||
id: "analytics",
|
||||
href: "/analytics", label: t("nav.analytics"), icon: Chart, action: null, className
|
||||
},
|
||||
{
|
||||
id: "notification",
|
||||
href: "/notification",
|
||||
label: t("nav.notification"),
|
||||
icon: Bell,
|
||||
className: cn(
|
||||
className,
|
||||
isNotificationOpen.value && "bg-primary/15",
|
||||
),
|
||||
action: handleNotificationClick,
|
||||
isActive: isNotificationOpen,
|
||||
expandComponent: unreadCount.value > 0 ? () => h('span', {
|
||||
class: 'absolute -top-2 -right-2 min-w-4 h-4 text-xs font-bold text-white bg-red rounded-full flex items-center justify-center'
|
||||
}, [unreadCount.value > 9 ? '9+' : unreadCount.value]) : undefined
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
href: "/settings", label: t("nav.settings"), icon: SettingsIcon, action: null, className
|
||||
},
|
||||
] as const;
|
||||
return baseLinks;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header
|
||||
class=":uno: fixed left-0 flex flex-col items-center pt-4 gap-6 z-41 max-h-screen h-screen bg-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.href">
|
||||
<component :name="i.label" :is="i.action ? 'div' : 'router-link'" v-bind="i.action ? {} : { to: i.href }"
|
||||
@click="i.action && i.action($event)" :class="cn(
|
||||
i.className,
|
||||
($route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value) && 'bg-primary/15 text-primary',
|
||||
)">
|
||||
<div class="relative">
|
||||
<component :is="i.icon" class="w-6 h-6 shrink-0"
|
||||
:filled="$route.path === i.href || $route.path.startsWith(i.href + '/') || i.isActive?.value" />
|
||||
<component v-if="i.expandComponent" :is="i.expandComponent" />
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
</header>
|
||||
<NotificationDrawer ref="notificationPopover" @change="(val) => (isNotificationOpen = val)" />
|
||||
</template>
|
||||
154
src/components/GlobalUploadIndicator.vue
Normal file
154
src/components/GlobalUploadIndicator.vue
Normal file
@@ -0,0 +1,154 @@
|
||||
<script setup lang="ts">
|
||||
import { useUploadQueue } from '@/composables/useUploadQueue';
|
||||
import UploadQueueItem from '@/routes/upload/components/UploadQueueItem.vue';
|
||||
import { useUIState } from '@/stores/uiState';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const { items, completeCount, pendingCount, startQueue, removeItem, cancelItem, removeAll } = useUploadQueue();
|
||||
const uiState = useUIState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const isCollapsed = ref(false);
|
||||
|
||||
const isVisible = computed(() => items.value.length > 0);
|
||||
|
||||
const overallProgress = computed(() => {
|
||||
if (items.value.length === 0) return 0;
|
||||
const total = items.value.reduce((acc, item) => acc + (item.progress || 0), 0);
|
||||
return Math.round(total / items.value.length);
|
||||
});
|
||||
|
||||
const isUploading = computed(() =>
|
||||
items.value.some(i => i.status === 'uploading' || i.status === 'fetching' || i.status === 'processing')
|
||||
);
|
||||
|
||||
const isAllDone = computed(() =>
|
||||
items.value.length > 0 && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||
);
|
||||
|
||||
const statusText = computed(() => {
|
||||
if (isAllDone.value) return t('upload.indicator.allDone');
|
||||
if (isUploading.value) {
|
||||
const count = items.value.filter(i => i.status === 'uploading' || i.status === 'fetching').length;
|
||||
return t('upload.indicator.uploading', { count });
|
||||
}
|
||||
if (pendingCount.value > 0) return t('upload.indicator.waiting', { count: pendingCount.value });
|
||||
return t('upload.queueItem.status.processing');
|
||||
});
|
||||
const isDoneWithErrors = computed(() =>
|
||||
isAllDone.value &&
|
||||
items.value.some(i => i.status === 'error') && items.value.every(i => i.status === 'complete' || i.status === 'error')
|
||||
);
|
||||
const doneUpload = () => {
|
||||
router.push({ name: 'videos', query: { uploaded: 'true' } });
|
||||
removeAll();
|
||||
}
|
||||
watch(isAllDone, (newItems) => {
|
||||
if (newItems && items.value.every(i => i.status === 'complete')) {
|
||||
const timeout = setTimeout(() => {
|
||||
doneUpload();
|
||||
clearTimeout(timeout);
|
||||
}, 3000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<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">
|
||||
|
||||
<div v-if="isVisible"
|
||||
class="fixed bottom-6 right-6 z-50 w-96 rounded-2xl bg-white shadow-[0_8px_40px_rgba(0,0,0,0.16)] border border-slate-100 overflow-hidden flex flex-col"
|
||||
style="max-height: 540px;">
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="flex items-center gap-3 px-4 py-3.5 bg-slate-800 text-white shrink-0 cursor-pointer select-none"
|
||||
@click="isCollapsed = !isCollapsed">
|
||||
|
||||
<!-- Status icon -->
|
||||
<div class="relative w-6 h-6 shrink-0">
|
||||
<svg v-if="isUploading" class="w-6 h-6 animate-spin text-accent" viewBox="0 0 24 24" fill="none">
|
||||
<circle class="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="3" />
|
||||
<path class="opacity-90" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
<svg v-else-if="isAllDone" class="w-6 h-6 text-green-400" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
<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>
|
||||
|
||||
<!-- Overall progress bar -->
|
||||
<div v-if="isUploading" class="h-0.5 w-full bg-slate-100 shrink-0">
|
||||
<div class="h-full bg-accent transition-all duration-500" :style="{ width: `${overallProgress}%` }">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File list -->
|
||||
<Transition enter-active-class="transition-all duration-200 ease-out" enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100" leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="!isCollapsed" class="flex-1 overflow-y-auto min-h-0">
|
||||
<div class="p-3 flex flex-col gap-2">
|
||||
<UploadQueueItem v-for="item in items" :key="item.id" :item="item" @remove="removeItem($event)"
|
||||
@cancel="cancelItem($event)" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
117
src/components/NotificationDrawer.vue
Normal file
117
src/components/NotificationDrawer.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import NotificationItem from '@/routes/notification/components/NotificationItem.vue';
|
||||
import { useNotifications } from '@/composables/useNotifications';
|
||||
import { onClickOutside } from '@vueuse/core';
|
||||
import { computed, onMounted, ref, watch } from 'vue';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import BellOff from './icons/BellOff.vue';
|
||||
|
||||
const isMounted = ref(false);
|
||||
onMounted(() => {
|
||||
isMounted.value = true;
|
||||
void notificationStore.fetchNotifications();
|
||||
});
|
||||
|
||||
const emit = defineEmits(['change']);
|
||||
const visible = ref(false);
|
||||
const drawerRef = ref(null);
|
||||
const { t } = useTranslation();
|
||||
const notificationStore = useNotifications();
|
||||
|
||||
const unreadCount = computed(() => notificationStore.unreadCount.value);
|
||||
const mutableNotifications = computed(() => notificationStore.notifications.value.slice(0, 8));
|
||||
|
||||
const toggle = (event?: Event) => {
|
||||
console.log(event);
|
||||
visible.value = !visible.value;
|
||||
if (visible.value && !notificationStore.loaded.value) {
|
||||
void notificationStore.fetchNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
onClickOutside(drawerRef, () => {
|
||||
if (visible.value) {
|
||||
visible.value = false;
|
||||
}
|
||||
}, {
|
||||
ignore: ['[name="Notification"]']
|
||||
});
|
||||
|
||||
const handleMarkRead = async (id: string) => {
|
||||
await notificationStore.markRead(id);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
await notificationStore.deleteNotification(id);
|
||||
};
|
||||
|
||||
const handleMarkAllRead = async () => {
|
||||
await notificationStore.markAllRead();
|
||||
};
|
||||
|
||||
watch(visible, (val) => {
|
||||
emit('change', val);
|
||||
});
|
||||
|
||||
defineExpose({ toggle });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport v-if="isMounted" to="body">
|
||||
<Transition enter-active-class="transition-all duration-300 ease-out"
|
||||
enter-from-class="opacity-0 -translate-x-4" enter-to-class="opacity-100 translate-x-0"
|
||||
leave-active-class="transition-all duration-200 ease-in" leave-from-class="opacity-100 translate-x-0"
|
||||
leave-to-class="opacity-0 -translate-x-4">
|
||||
<div v-if="visible" ref="drawerRef"
|
||||
class="fixed top-0 left-[80px] bottom-0 w-[380px] bg-white rounded-2xl border border-gray-300 p-3 z-50 flex flex-col shadow-lg my-3">
|
||||
<div class="flex items-center justify-between p-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-semibold text-gray-900">{{ t('notification.title') }}</h3>
|
||||
<span v-if="unreadCount > 0"
|
||||
class="px-2 py-0.5 text-xs font-medium bg-primary text-white rounded-full">
|
||||
{{ unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<button v-if="unreadCount > 0" @click="handleMarkAllRead"
|
||||
class="text-sm text-primary hover:underline font-medium">
|
||||
{{ t('notification.actions.markAllRead') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-y-auto gap-2">
|
||||
<template v-if="notificationStore.loading.value">
|
||||
<div v-for="i in 4" :key="i" class="p-4 rounded-xl border border-gray-200 animate-pulse">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 rounded-full bg-gray-200"></div>
|
||||
<div class="flex-1 space-y-2">
|
||||
<div class="h-4 bg-gray-200 rounded w-1/3"></div>
|
||||
<div class="h-3 bg-gray-200 rounded w-2/3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else-if="mutableNotifications.length > 0">
|
||||
<div v-for="notification in mutableNotifications" :key="notification.id"
|
||||
class="border-b border-gray-50 last:border-0">
|
||||
<NotificationItem :notification="notification" @mark-read="handleMarkRead"
|
||||
@delete="handleDelete" isDrawer />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="py-12 text-center">
|
||||
<BellOff class="w-12 h-12 text-gray-300 mx-auto block mb-3" />
|
||||
<p class="text-gray-500 text-sm">{{ t('notification.empty.title') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mutableNotifications.length > 0" class="p-3 border-t border-gray-100 bg-gray-50/50">
|
||||
<router-link to="/notification"
|
||||
class="block w-full text-center text-sm text-primary font-medium hover:underline"
|
||||
@click="visible = false">
|
||||
{{ t('notification.actions.viewAll') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
67
src/components/OfflineOverlay.vue
Normal file
67
src/components/OfflineOverlay.vue
Normal 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>
|
||||
75
src/components/PopupAdsRuntime.vue
Normal file
75
src/components/PopupAdsRuntime.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import { client as rpcClient } from '@/api/rpcclient';
|
||||
import ClientOnly from '@/components/ClientOnly';
|
||||
import { onMounted, onBeforeUnmount } from 'vue';
|
||||
|
||||
let activeItem: any | null = null;
|
||||
let clickHandler: ((event: MouseEvent) => void) | null = null;
|
||||
let scriptNode: HTMLScriptElement | null = null;
|
||||
let triggerCount = 0;
|
||||
|
||||
const triggerKey = (id: string) => `popup_ad_triggers:${id}`;
|
||||
|
||||
const cleanupScript = () => {
|
||||
if (scriptNode?.parentNode) {
|
||||
scriptNode.parentNode.removeChild(scriptNode);
|
||||
}
|
||||
scriptNode = null;
|
||||
};
|
||||
|
||||
const attachUrlHandler = () => {
|
||||
if (!activeItem?.id || typeof window === 'undefined') return;
|
||||
const maxTriggers = Number(activeItem.maxTriggersPerSession || 1);
|
||||
triggerCount = Number(sessionStorage.getItem(triggerKey(activeItem.id)) || '0');
|
||||
|
||||
clickHandler = () => {
|
||||
if (!activeItem?.value || triggerCount >= maxTriggers) return;
|
||||
triggerCount += 1;
|
||||
sessionStorage.setItem(triggerKey(activeItem.id), String(triggerCount));
|
||||
window.open(activeItem.value, '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
window.addEventListener('click', clickHandler, { capture: true });
|
||||
};
|
||||
|
||||
const attachScript = () => {
|
||||
if (!activeItem?.value || typeof document === 'undefined') return;
|
||||
cleanupScript();
|
||||
scriptNode = document.createElement('script');
|
||||
scriptNode.async = true;
|
||||
scriptNode.text = activeItem.value;
|
||||
document.body.appendChild(scriptNode);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await rpcClient.getActivePopupAd();
|
||||
activeItem = response.item || null;
|
||||
if (!activeItem?.isActive) return;
|
||||
|
||||
if (activeItem.type === 'script') {
|
||||
attachScript();
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeItem.type === 'url') {
|
||||
attachUrlHandler();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (clickHandler && typeof window !== 'undefined') {
|
||||
window.removeEventListener('click', clickHandler, { capture: true } as EventListenerOptions);
|
||||
}
|
||||
cleanupScript();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<span class="hidden" />
|
||||
</ClientOnly>
|
||||
</template>
|
||||
@@ -1,3 +1,12 @@
|
||||
<template>
|
||||
<router-view/>
|
||||
<ClientOnly>
|
||||
<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>
|
||||
|
||||
55
src/components/dashboard/EmptyState.vue
Normal file
55
src/components/dashboard/EmptyState.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
title: string;
|
||||
description?: string;
|
||||
icon?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="empty-state flex flex-col items-center justify-center py-12 px-6 text-center">
|
||||
<!-- Icon or Image -->
|
||||
<div v-if="imageUrl" class="mb-6">
|
||||
<img :src="imageUrl" :alt="title" class="w-64 h-64 object-contain opacity-80" />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="icon"
|
||||
class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center"
|
||||
>
|
||||
<span :class="[icon, 'w-12 h-12 text-gray-400']" />
|
||||
</div>
|
||||
<div v-else class="mb-6 w-24 h-24 rounded-full bg-gray-100 flex items-center justify-center">
|
||||
<span class="i-heroicons-inbox w-12 h-12 text-gray-400" />
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-2">{{ title }}</h3>
|
||||
<p v-if="description" class="text-gray-600 mb-6 max-w-md">{{ description }}</p>
|
||||
|
||||
<!-- Action Button -->
|
||||
<button
|
||||
v-if="actionLabel && onAction"
|
||||
@click="onAction"
|
||||
class="btn btn-outline-primary press-animated flex items-center gap-2"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
{{ actionLabel }}
|
||||
</button>
|
||||
|
||||
<!-- Slot for custom actions -->
|
||||
<slot name="actions" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.empty-state {
|
||||
min-height: 400px;
|
||||
}
|
||||
</style>
|
||||
77
src/components/dashboard/PageHeader.vue
Normal file
77
src/components/dashboard/PageHeader.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { type Component, VNode } from 'vue';
|
||||
|
||||
interface Breadcrumb {
|
||||
label: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
interface Action {
|
||||
label: string;
|
||||
icon?: string | VNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string | VNode | Component;
|
||||
description?: string;
|
||||
breadcrumbs?: Breadcrumb[];
|
||||
actions?: Action[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const getButtonClass = (variant?: string) => {
|
||||
const baseClass = 'px-4 py-2.5 rounded-lg font-medium transition-all press-animated flex items-center gap-2';
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
return `${baseClass} bg-primary hover:bg-primary-600 text-white shadow-sm`;
|
||||
case 'danger':
|
||||
return `${baseClass} bg-danger hover:bg-danger-600 text-white shadow-sm`;
|
||||
case 'secondary':
|
||||
default:
|
||||
return `${baseClass} bg-white hover:bg-gray-50 text-gray-700 border border-gray-300`;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="cn('page-header mb-6')">
|
||||
<!-- Breadcrumb -->
|
||||
<nav v-if="breadcrumbs && breadcrumbs.length" class="flex items-center gap-2 text-sm mb-2">
|
||||
<template v-for="(crumb, index) in breadcrumbs" :key="index">
|
||||
<router-link v-if="crumb.to" :to="crumb.to" class="text-gray-500 hover:text-primary transition-colors">
|
||||
{{ crumb.label }}
|
||||
</router-link>
|
||||
<span v-else class="text-gray-700 font-medium">{{ crumb.label }}</span>
|
||||
|
||||
<span v-if="index < breadcrumbs.length - 1" class="w-4 h-4 text-gray-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<!-- Title & Actions -->
|
||||
<div class="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div class="flex-1 min-w-0">
|
||||
<h1 v-if="typeof props.title == 'string'" class="text-2xl font-bold text-gray-900 mb-1">{{ title }}</h1>
|
||||
<component v-else :is="title" />
|
||||
<p v-if="description" class="text-gray-600">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="actions && actions.length" class="flex items-center gap-2 flex-shrink-0">
|
||||
<button v-for="(action, index) in actions" :key="index" @click="action.onClick"
|
||||
:class="getButtonClass(action.variant)">
|
||||
<component v-if="action.icon" :is="action.icon" class="w-5 h-5" />
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
86
src/components/dashboard/StatsCard.vue
Normal file
86
src/components/dashboard/StatsCard.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { VNode } from 'vue';
|
||||
|
||||
interface Trend {
|
||||
value: number;
|
||||
isPositive: boolean;
|
||||
}
|
||||
|
||||
export interface StatProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon?: string | VNode;
|
||||
trend?: Trend;
|
||||
color?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
}
|
||||
|
||||
withDefaults(defineProps<StatProps>(), {
|
||||
color: 'primary'
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
// const gradients = {
|
||||
// primary: 'from-primary/20 to-primary/5',
|
||||
// success: 'from-success/20 to-success/5',
|
||||
// warning: 'from-yellow-100 to-yellow-50',
|
||||
// danger: 'from-danger/20 to-danger/5',
|
||||
// info: 'from-info/20 to-info/5',
|
||||
// };
|
||||
|
||||
const iconColors = {
|
||||
primary: 'text-primary',
|
||||
success: 'text-success',
|
||||
warning: 'text-yellow-600',
|
||||
danger: 'text-danger',
|
||||
info: 'text-info',
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="[
|
||||
'transform translate-y-0 relative overflow-hidden rounded-2xl p-6 bg-header',
|
||||
// gradients[color],
|
||||
'border border-gray-300 transition-all duration-300',
|
||||
// 'group cursor-pointer'
|
||||
]">
|
||||
<!-- Content -->
|
||||
<div class="relative z-10">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div v-if="icon" :class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center',
|
||||
'bg-white/80 shadow-sm',
|
||||
iconColors[color]
|
||||
]">
|
||||
<component :is="icon" class="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Trend Indicator -->
|
||||
<div v-if="trend" class="flex items-center gap-1 text-sm">
|
||||
<span :class="[
|
||||
'flex items-center gap-1 font-medium',
|
||||
trend.isPositive ? 'text-success' : 'text-danger'
|
||||
]">
|
||||
<!-- <span :class="[
|
||||
'w-4 h-4',
|
||||
trend.isPositive ? 'i-heroicons-arrow-trending-up' : 'i-heroicons-arrow-trending-down'
|
||||
]" /> -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path v-if="trend.isPositive" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M3 17l6-6 4 4 8-8" />
|
||||
<path v-else stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 7l-6 6-4-4-8 8" />
|
||||
</svg>
|
||||
{{ Math.abs(trend.value) }}%
|
||||
</span>
|
||||
<span class="text-gray-500">{{ t('overview.stats.trendVsLastMonth') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
12
src/components/icons/ActivityIcon.vue
Normal file
12
src/components/icons/ActivityIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M3 3v18h18" />
|
||||
<path d="m19 9-5 5-4-4-3 3" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -6,14 +6,14 @@
|
||||
fill="#a6acb9" />
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" class="v-mid m-a" height="24" viewBox="-10 -226 468 468">
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
9
src/components/icons/AdvertisementIcon.vue
Normal file
9
src/components/icons/AdvertisementIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="var(--fill1)"/><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>
|
||||
10
src/components/icons/AlertTriangle.vue
Normal file
10
src/components/icons/AlertTriangle.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 580 524"><path d="M10 448c0 36 30 66 67 66h427c36 0 66-30 66-66 0-12-3-23-8-33L353 47c-13-23-37-37-63-37s-50 14-63 37L19 415c-6 10-9 21-9 33zm301-46c0 12-9 21-21 21s-21-9-21-21 9-21 21-21 21 9 21 21zm-35-238c0-8 6-14 14-14s14 6 14 14v168c0 8-6 14-14 14s-14-6-14-14V164z" fill="color-mix(in srgb, currentColor 40%, transparent)"/><path d="M290 423c-12 0-21-9-21-21s9-21 21-21 21 9 21 21-9 21-21 21zm14-91c0 8-6 14-14 14s-14-6-14-14V164c0-8 6-14 14-14s14 6 14 14v168z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="531" height="488" viewBox="-13 -224 531 488"><path d="M253-106c9 0 18 8 18 18V56c0 10-9 18-18 18-10 0-18-8-18-18V-88c0-10 8-18 18-18zm0 279c14 0 27-12 27-27s-13-27-27-27c-15 0-27 12-27 27s12 27 27 27zm-63-350c12-23 36-37 63-37 26 0 50 14 62 37l180 324c13 22 13 50 0 72s-37 35-62 35H73c-26 0-50-13-63-35s-13-50 0-72l180-324zm63-1c-14 0-26 7-32 19L41 165c-6 11-6 24 0 35 7 11 19 18 32 18h360c12 0 24-7 31-18 6-11 6-24 0-35L284-159c-6-12-18-19-31-19z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
16
src/components/icons/AlertTriangleIcon.vue
Normal file
16
src/components/icons/AlertTriangleIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z" />
|
||||
</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="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path>
|
||||
<line x1="12" y1="9" x2="12" y2="13"></line>
|
||||
<line x1="12" y1="17" x2="12.01" y2="17"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
17
src/components/icons/ArrowDownTray.vue
Normal file
17
src/components/icons/ArrowDownTray.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"
|
||||
v-if="!filled">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" v-else>
|
||||
<path fill-rule="evenodd"
|
||||
d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean
|
||||
}>()
|
||||
</script>
|
||||
15
src/components/icons/ArrowRightIcon.vue
Normal file
15
src/components/icons/ArrowRightIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
|
||||
stroke="none">
|
||||
<path d="M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z" />
|
||||
</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">
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
<polyline points="12 5 19 12 12 19"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,18 +1,11 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" height="24" width="24" viewBox="0 0 468 532">
|
||||
<path
|
||||
d="M66 378h337l-13-22c-24-40-36-85-36-131v-15c0-66-54-120-120-120s-120 54-120 120v15c0 46-12 91-35 131l-13 22z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M234 10c-13 0-24 11-24 24v10C129 55 66 125 66 210v15c0 37-10 74-29 107l-22 37c-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-166V34c0-13-11-24-24-24zm168 368H66l12-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-48H166z"
|
||||
fill="#1e3050" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else height="24" viewBox="-10 -258 468 532">
|
||||
<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="var(--fill1)"/><path d="M172 474c7 28 32 48 62 48s55-20 62-48H172z" fill="currentColor"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -258 468 532">
|
||||
<path
|
||||
d="M224-248c-13 0-24 11-24 24v10C119-203 56-133 56-48v15C56 4 46 41 27 74L5 111c-3 6-5 13-5 19 0 21 17 38 38 38h372c21 0 38-17 38-38 0-6-2-13-5-19l-22-37c-19-33-29-70-29-108v-14c0-85-63-155-144-166v-10c0-13-11-24-24-24zm168 368H56l12-22c24-40 36-85 36-131v-15c0-66 54-120 120-120s120 54 120 120v15c0 46 12 91 36 131l12 22zm-236 96c10 28 37 48 68 48s58-20 68-48H156z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/BellDot.vue
Normal file
7
src/components/icons/BellDot.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 660 535"><path d="M106 394c0 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 32v3c-73 15-128 80-128 157v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="var(--fill1)"/><path d="M616 28c5 12 0 26-12 31l-56 24c-13 5-27 0-32-13-5-12 0-26 13-31l56-24c12-5 26 0 31 13zM10 197c0-13 11-24 24-24h64c13 0 24 11 24 24s-11 24-24 24H34c-13 0-24-11-24-24zm258 280h124c-7 28-32 48-62 48s-55-20-62-48zm294-304h64c13 0 24 11 24 24s-11 24-24 24h-64c-13 0-24-11-24-24s11-24 24-24zM57 59c-13-5-18-19-13-31 5-13 19-18 32-13l56 24c12 5 17 19 12 31-5 13-19 18-31 13L57 59z" fill="var(--fill4)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="660" height="535" viewBox="-10 -261 660 535"><path d="M606-233c-5-13-19-18-31-13l-56 24c-13 5-18 19-13 31 5 13 19 18 31 13l56-24c13-5 18-19 13-31zm-286-15c-13 0-24 11-24 24v10c-81 11-144 81-144 166v15c0 37-10 74-29 107l-22 37c-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 368H152l12-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-48H252zM0-64c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24H24C11-88 0-77 0-64zm552-24c-13 0-24 11-24 24s11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64zM47-202l56 24c12 5 26 0 31-12 5-13 0-27-13-32l-56-24c-12-5-26 0-31 13-5 12 0 26 13 31z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/BellOff.vue
Normal file
7
src/components/icons/BellOff.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 600"><path d="M76 425c0 19 16 35 36 35h246L140 242v16c0 48-16 94-46 132l-10 12c-5 7-8 14-8 22zm0 0zm162 83c7 28 32 48 62 48s55-20 62-48H238z" fill="var(--fill1)"/><path d="M19 19c9-9 25-9 34 0l120 120c23-30 56-52 95-60v-3c0-18 14-32 32-32s32 14 32 32v3c73 15 128 80 128 157v22c0 48 16 95 46 132l10 12c5 7 8 14 8 23 0 17-13 32-30 35l87 87c9 10 9 25 0 34s-25 9-34 0L19 53c-9-9-9-25 0-34z" fill="var(--fill4)"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="600" height="601" viewBox="-12 -292 600 601"><path d="M41-273c-9-9-25-9-34 0s-9 25 0 34l528 528c9 10 25 10 34 0 9-9 9-24 0-34l-88-88c18-3 31-18 31-37 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-24s-24 11-24 24v10c-42 6-79 27-105 59L41-273zm152 152c22-29 56-47 95-47 66 0 120 54 120 120v15c0 46 12 91 36 131l12 22h-22L193-121zM133 98c19-33 31-71 34-109l-47-47v25c0 37-10 74-29 107l-22 37c-3 6-5 13-5 19 0 21 17 38 38 38h244l-48-48H120l13-22zm87 118c10 28 37 48 68 48s58-20 68-48H220z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/Chart.vue
Normal file
7
src/components/icons/Chart.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 600 564"><path d="M21 254c11 14 31 16 45 5l141-112 108 81c12 8 28 8 39-1l140-112v39c0 18 14 32 32 32s32-14 32-32V42c0-18-14-32-32-32H414c-18 0-32 14-32 32s14 32 32 32h29l-110 88-108-82c-11-8-28-8-39 1L26 209c-14 11-16 31-5 45zm25 108v96c0 18 14 32 32 32s32-14 32-32v-96c0-18-14-32-32-32s-32 14-32 32zm128-96v192c0 18 14 32 32 32s32-14 32-32V266c0-18-14-32-32-32s-32 14-32 32z" fill="var(--fill1)"/><path d="M446 554c80 0 144-64 144-144s-64-144-144-144-144 64-144 144 64 144 144 144zm0-240c9 0 16 7 16 16v8h16c9 0 16 7 16 16s-7 16-16 16h-46c-5 0-10 5-10 10s4 9 8 10l45 8c20 4 35 22 35 42 0 23-19 42-42 42h-6v8c0 9-7 16-16 16s-16-7-16-16v-8h-16c-9 0-16-7-16-16s7-16 16-16h54c5 0 10-4 10-10 0-5-4-9-8-10l-45-8c-20-4-35-21-35-42 0-22 18-41 40-42v-8c0-9 7-16 16-16z" fill="var(--fill4)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="0 0 599 564"><path d="M421 10c-13 0-24 11-24 24s11 24 24 24h53L333 178 221 80c-8-7-20-8-29-2L24 190c-11 7-14 22-7 33s22 14 33 7l153-102 114 100c9 8 23 8 32 0L509 91v55c0 13 11 24 24 24s24-11 24-24V34c0-13-11-24-24-24H421zM205 234c-13 0-24 11-24 24v208c0 13 11 24 24 24s24-11 24-24V258c0-13-11-24-24-24zM69 330c-13 0-24 11-24 24v112c0 13 11 24 24 24s24-11 24-24V354c0-13-11-24-24-24zm376 224c80 0 144-64 144-144s-64-144-144-144-144 64-144 144 64 144 144 144zm0-240c9 0 16 7 16 16v8h16c9 0 16 7 16 16s-7 16-16 16h-46c-5 0-10 5-10 10s4 9 8 10l45 8c20 4 35 22 35 42 0 23-19 42-42 42h-6v8c0 9-7 16-16 16s-16-7-16-16v-8h-16c-9 0-16-7-16-16s7-16 16-16h54c5 0 10-4 10-10 0-5-4-9-8-10l-45-8c-20-4-35-21-35-42 0-22 18-41 40-42v-8c0-9 7-16 16-16z" fill="var(--fill4)"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/CheckCircleIcon.vue
Normal file
16
src/components/icons/CheckCircleIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z" />
|
||||
</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="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,3 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 532 532"><path d="M10 266c0 37 21 69 51 85-10 33-2 70 24 96s63 34 96 24c16 30 48 51 85 51s69-21 85-51c33 10 70 2 96-24s34-63 24-96c30-16 51-48 51-85s-21-69-51-85c10-33 2-70-24-96s-63-34-96-24c-16-30-48-51-85-51s-69 21-85 51c-33-10-70-2-96 24s-34 63-24 96c-30 16-51 48-51 85zm152 42c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57z" fill="#a6acb9"/><path d="M339 166c8-11 23-14 33-6 11 8 13 23 6 34L255 363c-4 5-11 9-18 10-7 0-14-3-19-8l-56-57c-9-10-9-25 1-34 9-9 25-9 34 0l36 37 106-145z" fill="#1e3050"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/CheckMarkIcon.vue
Normal file
14
src/components/icons/CheckMarkIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<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="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z" />
|
||||
</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">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
10
src/components/icons/CoinsIcon.vue
Normal file
10
src/components/icons/CoinsIcon.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 532"><path d="M10 435c0 48 39 87 88 87h305c48 0 87-39 87-87 0-87-38-169-105-224l-48-41H164l-49 41C48 266 10 348 10 435zM138 36c0 4 1 8 3 12l37 74h144l37-74c2-4 3-8 3-12 0-14-12-26-26-26H164c-14 0-26 12-26 26zm44 275c0-29 23-53 52-53v-4c0-11 9-20 20-20s20 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-51z" fill="var(--fill1)"/><path d="M162 122c-13 0-24 11-24 24s11 24 24 24h176c13 0 24-11 24-24s-11-24-24-24H162zm92 112c-11 0-20 9-20 20v4c-29 0-52 24-52 53 0 25 19 47 44 51l42 7c6 1 10 7 10 13 0 7-6 12-12 12h-56c-11 0-20 9-20 20s9 20 20 20h24v4c0 11 9 20 20 20s20-9 20-20v-5c25-4 44-25 44-51s-18-48-44-52l-42-7c-6-1-10-6-10-13 0-6 6-12 13-12h47c11 0 20-9 20-20s-9-20-20-20h-8v-4c0-11-9-20-20-20z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="500" height="532" viewBox="6 -258 500 532"><path d="m379-191-46 81c84 77 163 154 163 279 0 52-43 95-95 95H111c-52 0-95-43-95-95C16 44 96-33 179-110l-46-81c-3-6-5-12-5-19 0-21 17-38 38-38h180c21 0 38 17 38 38 0 7-2 13-5 19zM227-88l-1 1C134-4 64 61 64 169c0 26 21 47 47 47h290c26 0 47-21 47-47C448 61 378-4 286-87l-1-1h-58zm-7-48h72l37-64H183l37 64zm40 96c11 0 20 9 20 20v4h8c11 0 20 9 20 20s-9 20-20 20h-47c-7 0-13 6-13 13 0 6 4 11 10 12l42 7c25 4 44 26 44 52s-19 47-44 51v5c0 11-9 20-20 20s-20-9-20-20v-4h-24c-11 0-20-9-20-20s9-20 20-20h56c6 0 12-5 12-12 0-6-4-12-10-13l-42-7c-25-4-44-26-44-51 0-29 23-53 52-53v-4c0-11 9-20 20-20z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="24" 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-else width="24" 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-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="var(--fill1)"/><path d="M10 106h512v64H10zm0 0z" fill="currentColor"/></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>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/CreditCardIcon.vue
Normal file
16
src/components/icons/CreditCardIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="M20 4H4c-1.11 0-1.99.89-1.99 2L2 18c0 1.11.89 2 2 2h16c1.11 0 2-.89 2-2V6c0-1.11-.89-2-2-2zm0 14H4v-6h16v6zm0-10H4V6h16v2z" />
|
||||
</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">
|
||||
<rect x="1" y="4" width="22" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="1" y1="10" x2="23" y2="10"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
13
src/components/icons/DownloadIcon.vue
Normal file
13
src/components/icons/DownloadIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
|
||||
<polyline points="7 10 12 15 17 10"/>
|
||||
<line x1="12" x2="12" y1="15" y2="3"/>
|
||||
</svg>
|
||||
</template>
|
||||
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
5
src/components/icons/EllipsisVerticalIcon.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 512" fill="currentColor">
|
||||
<path d="M64 360a56 56 0 1 0 0 112 56 56 0 1 0 0-112zm0-160a56 56 0 1 0 0 112 56 56 0 1 0 0-112zM120 96A56 56 0 1 0 8 96a56 56 0 1 0 112 0z"/>
|
||||
</svg>
|
||||
</template>
|
||||
23
src/components/icons/FileUploadType.vue
Normal file
23
src/components/icons/FileUploadType.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<template>
|
||||
<!-- Local file icon -->
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 404 532">
|
||||
<path
|
||||
d="M26 74v384c0 27 22 48 48 48h256c27 0 48-21 48-48V197c0-4 0-8-1-11H274c-31 0-56-25-56-56V27c-3-1-7-1-10-1H74c-26 0-48 22-48 48zm64 224c0-18 14-32 32-32h96c18 0 32 14 32 32v18l40-25c10-7 24 1 24 14v83c0 12-14 20-24 13l-40-25v18c0 18-14 32-32 32h-96c-18 0-32-14-32-32v-96z"
|
||||
fill="#a6acb9" />
|
||||
<path
|
||||
d="M208 26c3 0 7 0 10 1v103c0 31 25 56 56 56h103c1 3 1 7 1 11v261c0 27-21 48-48 48H74c-26 0-48-21-48-48V74c0-26 22-48 48-48h134zm156 137c2 2 4 4 6 7h-96c-22 0-40-18-40-40V34c3 2 5 4 7 6l123 123zM74 10c-35 0-64 29-64 64v384c0 35 29 64 64 64h256c35 0 64-29 64-64V197c0-17-7-34-19-46L253 29c-12-12-28-19-45-19H74zm144 272c9 0 16 7 16 16v96c0 9-7 16-16 16h-96c-9 0-16-7-16-16v-96c0-9 7-16 16-16h96zm-96-16c-18 0-32 14-32 32v96c0 18 14 32 32 32h96c18 0 32-14 32-32v-18l40 25c10 7 24-1 24-13v-84c0-12-14-20-24-13l-40 25v-18c0-18-14-32-32-32h-96zm176 38v84l-48-30v-24l48-30z"
|
||||
fill="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>
|
||||
10
src/components/icons/Globe.vue
Normal file
10
src/components/icons/Globe.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 627 563"><path d="M10 241h112c5-88 35-169 71-222C94 49 20 135 10 241zm0 48c10 106 84 193 183 222-36-52-66-134-71-222H10zm160-48h190c-4-62-22-121-45-165-13-25-27-44-38-56-6-5-10-8-12-10-2 2-6 5-12 10-11 12-25 31-38 56-23 44-41 103-45 165zm0 48c4 62 22 121 45 166 13 25 27 43 38 55 6 5 10 8 12 10 2-2 6-5 12-10 6-6 13-15 20-25-10-23-16-49-16-76 0-45 16-87 42-120H170zM337 19c34 50 64 126 70 210 21-8 43-12 66-12 15 0 30 2 44 5-16-97-87-175-180-203z" fill="var(--fill1)"/><path d="M473 553c80 0 144-64 144-144s-64-144-144-144-144 64-144 144 64 144 144 144zm87-145c-19-28-51-47-87-47s-68 19-87 47l-25-19c24-36 65-60 112-60s88 24 113 60l-26 19zm-23 17-26 19c-8-11-22-19-38-19s-30 8-38 19l-26-19c15-19 38-32 64-32s49 13 64 32zm-84 48c0-11 9-20 20-20s20 9 20 20-9 20-20 20-20-9-20-20z" fill="currentColor"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -258 628 564"><path d="M288 232c-1 0-7-1-18-12-12-11-24-28-36-49-22-39-39-92-41-147h160c17-19 38-35 62-46-7-76-37-145-70-187 81 22 144 87 162 169 12 1 23 3 34 5-21-121-126-213-253-213C147-248 32-133 32 8s115 256 256 256c17 0 33-2 49-5-10-14-18-30-23-47-3 3-5 6-7 8-12 11-18 12-19 12zM384-8H192c3-55 20-107 42-147 12-21 24-38 35-49 12-10 18-12 19-12s7 2 18 12c12 11 24 28 36 49 22 40 39 92 41 147zm0 0zM160-8H65c6-97 75-177 166-201-35 44-67 120-71 201zM65 24h95c4 82 36 157 71 201C140 201 71 121 65 24zm431 16c62 0 112 50 112 112s-50 112-112 112-112-50-112-112S434 40 496 40zm0 256c80 0 144-64 144-144S576 8 496 8 352 72 352 152s64 144 144 144zm96-165c-24-26-58-43-96-43s-72 17-96 43l25 21c17-20 43-32 71-32s54 12 71 32l25-21zm-96 13c-21 0-41 8-55 22l25 21c8-7 19-11 30-11 12 0 22 4 30 11l25-21c-14-14-34-22-55-22zm0 92c11 0 20-9 20-20s-9-20-20-20-20 9-20 20 9 20 20 20z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
14
src/components/icons/GlobeIcon.vue
Normal file
14
src/components/icons/GlobeIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-6 h-6">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
</svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-6 h-6">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="2" y1="12" x2="22" y2="12"></line>
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/HardDriveUpload.vue
Normal file
7
src/components/icons/HardDriveUpload.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<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="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="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
11
src/components/icons/HeartIcon.vue
Normal file
11
src/components/icons/HeartIcon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20.42 4.58a5.4 5.4 0 0 0-7.65 0l-.77.78-.77-.78a5.4 5.4 0 0 0-7.65 0C1.46 6.7 1.33 10.28 4 13l8 8 8-8c2.67-2.72 2.54-6.3.42-8.42z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -1,18 +1,17 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="v-mid m-a" height="24" width="24"
|
||||
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"
|
||||
fill="#a6acb9" />
|
||||
fill="var(--fill1)" />
|
||||
<path
|
||||
d="M247 22c13-12 32-12 44 0l224 208c13 12 13 32 1 45s-32 14-45 2L269 89 67 276c-13 12-33 12-45-1s-12-33 1-45L247 22z"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" viewBox="-11 -259 535 533">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
13
src/components/icons/ImageIcon.vue
Normal file
13
src/components/icons/ImageIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<circle cx="9" cy="9" r="2" />
|
||||
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/components/icons/Inbox.vue
Normal file
7
src/components/icons/Inbox.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 468"><path d="M10 268v126c0 35 29 64 64 64h384c35 0 64-29 64-64V268c0-3 0-6-1-9L494 65c-5-32-32-55-64-55H102c-32 0-59 23-64 55L11 259c-1 3-1 6-1 9zm64-2 28-192h328l28 192h-60c-12 0-23 7-29 18l-14 28c-6 11-17 18-29 18H206c-12 0-23-7-29-18l-14-28c-5-11-17-18-29-18H74z" fill="var(--fill1)"/><path d="M249 291c9 9 25 9 34 0l64-64c9-9 9-25 0-34s-25-9-34 0l-23 23v-86c0-13-11-24-24-24s-24 11-24 24v86l-23-23c-9-9-25-9-34 0-9 10-9 25 0 34l64 64z" fill="var(--fill4)"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="532" height="468" viewBox="-10 -226 532 468"><path d="M98-184c-16 0-30 12-32 27L35 56h100c11 0 21 5 27 14l23 34h142l23-34c6-9 16-14 27-14h100l-31-213c-2-15-16-27-31-27H98zM32 168c0 18 14 32 32 32h384c18 0 32-14 32-32V88H377l-23 34c-6 9-16 14-26 14H185c-11 0-21-5-27-14l-23-34H32v80zm2-329c5-32 32-55 64-55h317c31 0 58 23 63 55l33 227c1 3 1 6 1 10v92c0 35-29 64-64 64H64c-35 0-64-29-64-64V76c0-4 0-7 1-10l33-227zM339-21l-72 72c-6 7-16 7-22 0l-72-72c-6-6-6-16 0-22s16-6 22 0l45 44v-121c0-9 7-16 16-16s16 7 16 16V1l45-44c6-6 16-6 22 0 7 6 7 16 0 22z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/InfoIcon.vue
Normal file
16
src/components/icons/InfoIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
|
||||
</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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="12" y1="16" x2="12" y2="12"></line>
|
||||
<line x1="12" y1="8" x2="12.01" y2="8"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/LanguageIcon.vue
Normal file
7
src/components/icons/LanguageIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 600 536"><path d="M269 477c58-131 80-180 128-288 5-12 16-19 29-19s24 7 29 19l128 288c7 16 0 35-16 42s-35 0-42-16l-20-45H347l-20 45c-7 16-26 23-42 16s-23-26-16-42zm107-83h100l-50-113-50 113z" fill="var(--fill1)"/><path d="M170 10c18 0 32 14 32 32v32h128c18 0 32 14 32 32s-14 32-32 32h-10l-8 23c-16 45-41 87-72 122 14 9 29 17 44 24l51 22-26 59-51-23c-23-10-45-22-66-36-21 17-44 32-69 44l-35 18c-15 8-35 1-43-15-7-15-1-35 15-43l34-17c17-8 32-18 47-28-14-13-27-27-39-41l-21-24c-11-14-9-34 5-46 13-11 33-9 45 5l20 24c11 14 24 27 37 39 28-31 50-66 64-106v-1H42c-18 0-32-14-32-32s14-32 32-32h96V42c0-18 14-32 32-32z" fill="var(--fill3)"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 599 535"><path d="M178 10c13 0 24 11 24 24v56h136c13 0 24 11 24 24s-11 24-24 24h-16l-17 38c-18 44-45 83-78 116 14 10 29 18 44 25l61 28 72-161c4-8 13-14 22-14 10 0 18 6 22 14l136 304c5 12 0 27-12 32s-26 0-32-12l-29-66H341l-29 66c-5 12-20 17-32 12s-17-20-12-32l45-99-61-29c-22-9-42-21-61-35-18 14-37 26-57 36l-57 30c-12 7-26 2-32-10-6-11-2-26 10-32l57-30c14-7 27-16 40-25-27-26-51-55-70-88-6-11-3-26 9-33 11-6 26-2 33 9 17 30 39 58 65 81 31-30 55-66 72-106l9-19H34c-13 0-24-11-24-24s11-24 24-24h120V34c0-13 11-24 24-24zm311 384-63-141-63 141h126z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -6,14 +6,14 @@
|
||||
fill="#a6acb9" />
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</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">
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
13
src/components/icons/LayoutDashboard.vue
Normal file
13
src/components/icons/LayoutDashboard.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
|
||||
<line x1="3" x2="21" y1="9" y2="9" />
|
||||
<line x1="9" x2="9" y1="21" y2="9" />
|
||||
</svg>
|
||||
</template>
|
||||
7
src/components/icons/LinkIcon.vue
Normal file
7
src/components/icons/LinkIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<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="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="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/ListIcon.vue
Normal file
7
src/components/icons/ListIcon.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M10 170v224c0 35 29 64 64 64h320c35 0 64-29 64-64V170H10zm96 88c0-13 11-24 24-24h208c13 0 24 11 24 24s-11 24-24 24H130c-13 0-24-11-24-24zm0 112c0-13 11-24 24-24h208c13 0 24 11 24 24s-11 24-24 24H130c-13 0-24-11-24-24z" fill="var(--fill1)"/><path d="M74 10c-35 0-64 29-64 64v96h448V74c0-35-29-64-64-64H74zm240 48h64c7 0 12 4 15 10 2 6 1 13-4 17l-32 32c-6 7-16 7-22 0l-32-32c-5-4-6-11-4-17 3-6 9-10 15-10z" fill="currentColor"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-10 -226 468 468"><path d="M64-184h320c18 0 32 14 32 32v64H32v-64c0-18 14-32 32-32zM32-56h384v224c0 18-14 32-32 32H64c-18 0-32-14-32-32V-56zM0-152v320c0 35 29 64 64 64h320c35 0 64-29 64-64v-320c0-35-29-64-64-64H64c-35 0-64 29-64 64zM112 8c-9 0-16 7-16 16s7 16 16 16h224c9 0 16-7 16-16s-7-16-16-16H112zm0 96c-9 0-16 7-16 16s7 16 16 16h224c9 0 16-7 16-16s-7-16-16-16H112zm200-260c-5 0-9 3-11 7-2 5-1 10 3 14l24 24c4 4 12 4 17 0l24-24c3-4 4-9 2-14-2-4-6-7-11-7h-48z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
12
src/components/icons/LockIcon.vue
Normal file
12
src/components/icons/LockIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/MailIcon.vue
Normal file
13
src/components/icons/MailIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" viewBox="0 0 532 404"><path d="M10 58c0 15 7 29 19 38l208 156c17 13 41 13 58 0L503 96c12-9 19-23 19-38v272c0 35-29 64-64 64H74c-35 0-64-29-64-64V58z" fill="var(--fill1)"/><path d="M58 10c-26 0-48 22-48 48 0 15 7 29 19 38l208 156c17 13 41 13 58 0L503 96c12-9 19-23 19-38 0-26-21-48-48-48H58z" fill="var(--fill4)"/></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">
|
||||
<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>
|
||||
10
src/components/icons/MoneyCheck.vue
Normal file
10
src/components/icons/MoneyCheck.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 610 500"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v73c-21 3-41 13-58 29l-58 58H306c-13 0-24 11-24 24s11 24 24 24h52l-64 64c-13 14-23 30-28 48H74c-35 0-64-29-64-64V74zm76 93c0 25 19 47 44 51l42 7c6 1 10 7 10 13 0 7-5 12-12 12h-56c-11 0-20 9-20 20s9 20 20 20h24v4c0 11 9 20 20 20s20-9 20-20v-5c25-4 44-25 44-51s-18-48-44-52l-41-7c-6-1-11-6-11-12 0-7 6-13 13-13h47c11 0 20-9 20-20s-9-20-20-20h-8v-4c0-11-9-20-20-20s-20 9-20 20v4c-29 0-52 24-52 53zm196-21c0 13 11 24 24 24h128c13 0 24-11 24-24s-11-24-24-24H306c-13 0-24 11-24 24z" fill="var(--fill1)"/><path d="m298 473 12-60c3-12 9-24 18-33l119-119 80 80-119 119c-9 9-20 15-33 18l-59 12h-3c-8 0-15-7-15-15v-3zm0 0zm251-154-80-80 29-29c22-22 58-22 80 0s22 58 0 80l-29 29z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 607 500"><path d="M74 42h384c18 0 32 14 32 32v81c11-3 22-5 32-5V74c0-35-28-64-64-64H74c-35 0-64 29-64 64v256c0 35 29 64 64 64h189l1-4c1-9 4-19 8-28H74c-17 0-32-14-32-32V74c0-18 15-32 32-32zm240 192c-8 0-16 7-16 16s8 16 16 16h44l32-32h-76zm-16-80c0 9 8 16 16 16h96c9 0 16-7 16-16s-7-16-16-16h-96c-8 0-16 7-16 16zM170 98c-8 0-16 7-16 16v8h-1c-26 0-47 21-47 46 0 23 17 42 39 46l45 8c7 1 12 7 12 14 0 8-6 14-14 14h-58c-8 0-16 7-16 16s8 16 16 16h24v8c0 9 8 16 16 16 9 0 16-7 16-16v-8h2c26 0 46-21 46-46 0-23-16-42-38-46l-46-8c-6-1-12-7-12-14 0-8 7-14 15-14h49c9 0 16-7 16-16s-7-16-16-16h-16v-8c0-9-7-16-16-16zm182 288 102-102 50 51-102 102c-4 5-11 8-17 9l-51 8 9-51c1-6 4-12 8-17zm0 0zm124-125 21-20c14-14 37-14 51 0s14 36 0 50l-21 21-51-51zM311 398l-12 75c0 1-1 1-1 2 0 8 7 15 15 15h3l74-13c13-2 26-8 35-17l145-146c27-26 27-69 0-96-26-26-69-26-96 0L329 364c-9 9-16 21-18 34z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
13
src/components/icons/MonitorIcon.vue
Normal file
13
src/components/icons/MonitorIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="14" x="2" y="3" rx="2" />
|
||||
<line x1="8" x2="16" y1="21" y2="21" />
|
||||
<line x1="12" x2="12" y1="17" y2="21" />
|
||||
</svg>
|
||||
</template>
|
||||
17
src/components/icons/PanelLeft.vue
Normal file
17
src/components/icons/PanelLeft.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
defineProps<{
|
||||
class?: string;
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :class="cn('w-6 h-6', $props.class)" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M19 21H5C3.89543 21 3 20.1046 3 19V5C3 3.89543 3.89543 3 5 3H19C20.1046 3 21 3.89543 21 5V19C21 20.1046 20.1046 21 19 21Z"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M9 3V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/components/icons/PencilIcon.vue
Normal file
8
src/components/icons/PencilIcon.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 534 533"><path d="m303 93 104 104 34 34 62-62c14-13 21-32 21-51s-7-38-21-51l-36-36c-13-13-32-21-51-21s-38 8-51 21l-62 62z" fill="#a6acb9"/><path d="m95 377-24 87 86-25c7-1 13-5 18-10l-71-69c-4 5-8 10-9 17zm174-250 34-34 104 104 34 34-34 34-198 198c-11 11-24 19-39 23L42 521c-8 2-17 0-23-6s-9-15-6-23l35-128c5-15 12-28 23-39l198-198z" fill="#1e3050"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="534" height="533" viewBox="-12 -258 534 533"><path d="M404-216c-11 0-21 4-28 12l-59 58 93 93 58-58c8-8 12-18 12-29s-4-21-12-28l-35-36c-8-8-18-12-29-12zM93 78l93 93L387-30l-93-93L93 78zm-21 25c-2 3-4 7-5 11L36 229l114-32c4-1 8-3 11-5l-89-89zm281-330c13-13 32-21 51-21s38 8 51 21l36 36c13 13 21 32 21 51s-8 38-21 51L197 205c-11 11-24 19-39 23L30 263c-8 2-17 0-23-6s-9-15-6-23l35-128c5-15 12-28 23-39l294-294z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
11
src/components/icons/PlayIcon.vue
Normal file
11
src/components/icons/PlayIcon.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
</svg>
|
||||
</template>
|
||||
12
src/components/icons/PlusIcon.vue
Normal file
12
src/components/icons/PlusIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12"/>
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/PlusSquareIcon.vue
Normal file
13
src/components/icons/PlusSquareIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M12 8v8" />
|
||||
<path d="M8 12h8" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/RepeatIcon.vue
Normal file
14
src/components/icons/RepeatIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 21h5v-5" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/SendIcon.vue
Normal file
13
src/components/icons/SendIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 628 628"><path d="M286 343 618 10 394 618 286 343z" fill="var(--fill1)"/><path d="M618 10 10 234l276 109L618 10z" fill="var(--fill4)"/></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="m22 2-7 20-4-9-9-4Z" />
|
||||
<path d="M22 2 11 13" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/SettingsIcon.vue
Normal file
14
src/components/icons/SettingsIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 567 580"><path d="M18 190c-8 14-6 32 5 43l37 36v42l-37 36c-11 12-13 29-5 43l46 80c8 14 24 21 40 17l50-14c11 8 23 15 36 21l13 50c4 15 18 26 34 26h93c16 0 30-11 34-26l13-50c13-6 25-13 36-21l50 14c15 4 32-3 40-17l46-80c8-14 6-31-6-43l-37-36c1-7 1-14 1-21s0-14-1-21l37-36c12-11 14-29 6-43l-46-80c-8-14-24-21-40-17l-50 14c-11-8-23-15-36-21l-13-50c-4-15-18-26-34-26h-93c-16 0-30 11-34 26l-13 50c-13 6-25 13-36 21l-50-13c-16-5-32 2-40 16l-46 80zm377 100c1 41-20 79-55 99-35 21-79 21-114 0-35-20-56-58-54-99-2-41 19-79 54-99 35-21 79-21 114 0 35 20 56 58 55 99zm-195 0c-2 31 14 59 40 75 27 15 59 15 86 0 26-16 42-44 41-75 1-31-15-59-41-75-27-15-59-15-86 0-26 16-42 44-40 75z" fill="var(--fill1)"/><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="currentColor"/></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="M12.22 2h-.44a2 2 0 0 1-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.1a2 2 0 0 1-1-1.72v-.51a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z">
|
||||
</path>
|
||||
<circle cx="12" cy="12" r="3"></circle>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
15
src/components/icons/SlidersIcon.vue
Normal file
15
src/components/icons/SlidersIcon.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect width="20" height="16" x="2" y="4" rx="2" />
|
||||
<circle cx="8" cy="10" r="2" />
|
||||
<path d="M16 10h.01" />
|
||||
<path d="M12 10h.01" />
|
||||
<path d="M2 14h20" />
|
||||
</svg>
|
||||
</template>
|
||||
9
src/components/icons/TelegramIcon.vue
Normal file
9
src/components/icons/TelegramIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="516" height="516" viewBox="-2 -250 516 516"><path d="M256-240C119-240 8-129 8 8s111 248 248 248S504 145 504 8 393-240 256-240zM371-71c-4 39-20 134-28 178-4 19-10 25-17 25-14 2-25-9-39-18-22-15-34-23-56-37-24-17-8-25 6-40 3-4 67-61 68-67 0 0 0-3-1-4-2-1-4-1-5-1-2 1-37 24-105 70-10 6-19 10-27 9-9 0-26-5-38-9-16-5-28-7-27-16 0-4 7-9 18-14 73-31 121-52 145-62 69-29 83-34 92-34 2 0 7 1 10 3 2 2 3 4 3 7 1 3 1 6 1 10z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
14
src/components/icons/TrashIcon.vue
Normal file
14
src/components/icons/TrashIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 548"><path d="M42 122v352c0 35 29 64 64 64h256c35 0 64-29 64-64V122H42zm64 88c0-13 11-24 24-24s24 11 24 24v240c0 13-11 24-24 24s-24-11-24-24V210zm104 0c0-13 11-24 24-24s24 11 24 24v240c0 13-11 24-24 24s-24-11-24-24V210zm104 0c0-13 11-24 24-24s24 11 24 24v240c0 13-11 24-24 24s-24-11-24-24V210z" fill="color-mix(in srgb, var(--colors-danger-DEFAULT) 40%, transparent)"/><path d="M177 10c-14 0-26 9-30 22l-9 26H42c-18 0-32 14-32 32s14 32 32 32h384c18 0 32-14 32-32s-14-32-32-32h-96l-9-26c-4-13-16-22-30-22H177z" fill="var(--colors-danger-DEFAULT)"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="3 6 5 6 21 6"></polyline>
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
|
||||
<line x1="10" y1="11" x2="10" y2="17"></line>
|
||||
<line x1="14" y1="11" x2="14" y2="17"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="28" 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
|
||||
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="var(--fill1)" />
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="28" 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
|
||||
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>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
13
src/components/icons/UploadIcon.vue
Normal file
13
src/components/icons/UploadIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||
<polyline points="17 8 12 3 7 8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</svg>
|
||||
</template>
|
||||
10
src/components/icons/UserIcon copy.vue
Normal file
10
src/components/icons/UserIcon copy.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="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>
|
||||
10
src/components/icons/UserIcon.vue
Normal file
10
src/components/icons/UserIcon.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 531"><path d="M10 150c1 99 41 281 214 363 16 8 36 8 52 0 173-82 214-264 214-363 0-26-16-48-38-57L263 13c-4-2-9-3-13-3s-8 1-12 3L48 93c-22 9-38 31-38 57zm128 212c0-44 36-80 80-80h64c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16zm168-176c0 31-25 56-56 56s-56-25-56-56 25-56 56-56 56 25 56 56z" fill="#a6acb9"/><path d="M282 282c44 0 80 36 80 80 0 9-7 16-16 16H154c-9 0-16-7-16-16 0-44 36-80 80-80h64zm-32-40c-31 0-56-25-56-56s25-56 56-56 56 25 56 56-25 56-56 56z" fill="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>
|
||||
@@ -1,16 +1,16 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="24" 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"
|
||||
fill="#a6acb9" />
|
||||
fill="var(--fill1)" />
|
||||
<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="currentColor" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else width="24" viewBox="22 -194 564 404">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
|
||||
<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"
|
||||
fill="#1e3050" />
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ class?: string, filled?: boolean }>();
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
16
src/components/icons/VideoIcon.vue
Normal file
16
src/components/icons/VideoIcon.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<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="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z" />
|
||||
</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">
|
||||
<polygon points="23 7 16 12 23 17 23 7"></polygon>
|
||||
<rect x="1" y="5" width="15" height="14" rx="2" ry="2"></rect>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
9
src/components/icons/VideoPlayIcon.vue
Normal file
9
src/components/icons/VideoPlayIcon.vue
Normal file
@@ -0,0 +1,9 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 564 468"><path d="M10 74v256c0 35 29 64 64 64h161c-11-24-17-51-17-80 0-106 86-192 192-192 42 0 81 13 112 36V74c0-15-5-29-14-40l-46 46c-16-4-34-6-52-6h-10l64-64h-92l-1 1-95 95h-68l96-96h-92l-1 1-95 95H48l96-96H74c-35 0-64 29-64 64z" fill="var(--fill1)"/><path d="M266 314c0-80 64-144 144-144s144 64 144 144-64 144-144 144-144-64-144-144zm104-62c-5 3-8 8-8 14v96c0 6 3 11 8 14s11 3 16 0l80-48c5-3 8-8 8-14s-3-11-8-14l-80-48c-5-3-11-3-16 0z" 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>
|
||||
14
src/components/icons/VolumeIcon.vue
Normal file
14
src/components/icons/VolumeIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" x2="20" y1="21" y2="21" />
|
||||
<polygon points="12 11 4 18 4 6 12 11" />
|
||||
<path d="M16 8.73a2 2 0 0 1 0 3.55" />
|
||||
<path d="M18 5.05a6 6 0 0 1 0 10.9" />
|
||||
</svg>
|
||||
</template>
|
||||
13
src/components/icons/VolumeOffIcon.vue
Normal file
13
src/components/icons/VolumeOffIcon.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
|
||||
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
|
||||
</svg>
|
||||
</template>
|
||||
14
src/components/icons/WifiIcon.vue
Normal file
14
src/components/icons/WifiIcon.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5 12.55a11 11 0 0 1 14.08 0" />
|
||||
<path d="M1.42 9a16 16 0 0 1 21.16 0" />
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0" />
|
||||
<line x1="12" x2="12.01" y1="20" y2="20" />
|
||||
</svg>
|
||||
</template>
|
||||
17
src/components/icons/XCircleIcon.vue
Normal file
17
src/components/icons/XCircleIcon.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<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="M12 2C6.47 2 2 6.47 2 12s4.47 10 10 10 10-4.47 10-10S17.53 2 12 2zm5 13.59L15.59 17 12 13.41 8.41 17 7 15.59 10.59 12 7 8.41 8.41 7 12 10.59 15.59 7 17 8.41 13.41 12 17 15.59z" />
|
||||
</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">
|
||||
<circle cx="12" cy="12" r="10"></circle>
|
||||
<line x1="15" y1="9" x2="9" y2="15"></line>
|
||||
<line x1="9" y1="9" x2="15" y2="15"></line>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
12
src/components/icons/XIcon.vue
Normal file
12
src/components/icons/XIcon.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{
|
||||
filled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</template>
|
||||
8
src/components/icons/hard-drive.vue
Normal file
8
src/components/icons/hard-drive.vue
Normal file
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 468"><path d="M10 74c0-35 29-64 64-64h320c35 0 64 29 64 64v256c0-35-29-64-64-64H74c-35 0-64 29-64 64V74zm288 288c0 18-14 32-32 32s-32-14-32-32 14-32 32-32 32 14 32 32z" fill="var(--fill1)"/><path d="M10 330c0-35 29-64 64-64h320c35 0 64 29 64 64v64c0 35-29 64-64 64H74c-35 0-64-29-64-64v-64zm288 32c0-18-14-32-32-32s-32 14-32 32 14 32 32 32 32-14 32-32zm64 32c18 0 32-14 32-32s-14-32-32-32-32 14-32 32 14 32 32 32z" fill="currentColor"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="-10 -226 468 468"><path d="M64-184c-18 0-32 14-32 32V17c9-6 20-9 32-9h320c12 0 23 3 32 9v-169c0-18-14-32-32-32H64zM32 72v96c0 18 14 32 32 32h320c18 0 32-14 32-32V72c0-18-14-32-32-32H64c-18 0-32 14-32 32zM0 72v-224c0-35 29-64 64-64h320c35 0 64 29 64 64v320c0 35-29 64-64 64H64c-35 0-64-29-64-64V72zm256 24c13 0 24 11 24 24s-11 24-24 24-24-11-24-24 11-24 24-24zm96 0c13 0 24 11 24 24s-11 24-24 24-24-11-24-24 11-24 24-24z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
@@ -1,42 +0,0 @@
|
||||
import { createStaticVNode } from "vue";
|
||||
|
||||
export const Home = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M4.6 22.73A107 107 0 0 0 11 23h2.22c2.43-.04 4.6-.16 6.18-.27A3.9 3.9 0 0 0 23 18.8v-8.46a4 4 0 0 0-1.34-3L14.4.93a3.63 3.63 0 0 0-4.82 0L2.34 7.36A4 4 0 0 0 1 10.35v8.46a3.9 3.9 0 0 0 3.6 3.92M13.08 2.4l7.25 6.44a2 2 0 0 1 .67 1.5v8.46a1.9 1.9 0 0 1-1.74 1.92q-1.39.11-3.26.19V16a4 4 0 0 0-8 0v4.92q-1.87-.08-3.26-.19A1.9 1.9 0 0 1 3 18.81v-8.46a2 2 0 0 1 .67-1.5l7.25-6.44a1.63 1.63 0 0 1 2.16 0M13.12 21h-2.24a1 1 0 0 1-.88-1v-4a2 2 0 1 1 4 0v4a1 1 0 0 1-.88 1">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const HomeFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M9.59.92a3.63 3.63 0 0 1 4.82 0l7.25 6.44A4 4 0 0 1 23 10.35v8.46a3.9 3.9 0 0 1-3.6 3.92 106 106 0 0 1-14.8 0A3.9 3.9 0 0 1 1 18.8v-8.46a4 4 0 0 1 1.34-3zM12 16a5 5 0 0 1-3.05-1.04l-1.23 1.58a7 7 0 0 0 8.56 0l-1.23-1.58A5 5 0 0 1 12 16">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Dashboard = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M23 5a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4zm-10 6V3h6a2 2 0 0 1 2 2v6zm8 8a2 2 0 0 1-2 2h-6v-8h8zM5 3h6v18H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const DashboardFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M11 23H5a4 4 0 0 1-4-4V5a4 4 0 0 1 4-4h6zm12-4a4 4 0 0 1-4 4h-6V13h10zM19 1a4 4 0 0 1 4 4v6H13V1z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Add = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M11 11H6v2h5v5h2v-5h5v-2h-5V6h-2zM5 1a4 4 0 0 0-4 4v14a4 4 0 0 0 4 4h14a4 4 0 0 0 4-4V5a4 4 0 0 0-4-4zm16 4v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5c0-1.1.9-2 2-2h14a2 2 0 0 1 2 2">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const AddFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M1 5a4 4 0 0 1 4-4h14a4 4 0 0 1 4 4v14a4 4 0 0 1-4 4H5a4 4 0 0 1-4-4zm10 6H6v2h5v5h2v-5h5v-2h-5V6h-2z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const Bell = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M16 19h8v-2h-.34a3.15 3.15 0 0 1-3.12-2.76l-.8-6.41a7.8 7.8 0 0 0-15.48 0l-.8 6.41A3.15 3.15 0 0 1 .34 17H0v2h8v1h.02a3.4 3.4 0 0 0 3.38 3h1.2a3.4 3.4 0 0 0 3.38-3H16zm1.75-10.92.8 6.4c.12.95.5 1.81 1.04 2.52H4.4c.55-.7.92-1.57 1.04-2.51l.8-6.41a5.8 5.8 0 0 1 11.5 0M13.4 19c.33 0 .6.27.6.6 0 .77-.63 1.4-1.4 1.4h-1.2a1.4 1.4 0 0 1-1.4-1.4c0-.33.27-.6.6-.6z">
|
||||
</path>
|
||||
</svg>`, 1);
|
||||
export const BellFilled = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24">
|
||||
<path
|
||||
d="M20.54 14.24A3.15 3.15 0 0 0 23.66 17H24v2h-8v1h-.02a3.4 3.4 0 0 1-3.38 3h-1.2a3.4 3.4 0 0 1-3.38-3H8v-1H0v-2h.34a3.15 3.15 0 0 0 3.12-2.76l.8-6.41a7.8 7.8 0 0 1 15.48 0zM10 19.6c0 .77.63 1.4 1.4 1.4h1.2c.77 0 1.4-.63 1.4-1.4a.6.6 0 0 0-.6-.6h-2.8a.6.6 0 0 0-.6.6" ></path>
|
||||
</svg>`, 1);
|
||||
export const Search = createStaticVNode(`<svg aria-hidden="true" aria-label="" class="v-mid m-a" height="24" role="img" viewBox="0 0 24 24" width="24"><path d="M17.33 18.74a10 10 0 1 1 1.41-1.41l4.47 4.47-1.41 1.41zM11 3a8 8 0 1 0 0 16 8 8 0 0 0 0-16"></path></svg>`, 1);
|
||||
7
src/components/icons/shield-user.vue
Normal file
7
src/components/icons/shield-user.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<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="var(--fill1)"/><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="500" height="529" viewBox="6 -257 500 529"><path d="M231-240c16-7 34-7 50 0l177 75 4 2c18 9 32 28 34 50v24c-6 102-52 267-214 344l-5 2c-11 5-24 5-35 2l-6-1-6-3C68 178 22 14 17-88l-1-20c0-25 14-45 34-55l4-2 177-75zm38 29c-7-3-15-3-22-1l-3 1-177 75c-11 5-19 16-19 28l1 18c5 96 48 246 194 316l4 2c7 2 15 2 22-2l14-7c144-78 181-236 181-327v-3c-1-10-7-19-17-24l-2-1-176-75zm19 235c44 0 80 36 80 80 0 9-7 16-16 16s-16-7-16-16c0-26-21-48-48-48h-64c-26 0-48 22-48 48 0 9-7 16-16 16s-16-7-16-16c0-44 36-80 80-80h64zM256-8c-35 0-64-29-64-64s29-64 64-64 64 29 64 64-29 64-64 64zm0-96c-18 0-32 14-32 32s14 32 32 32 32-14 32-32-14-32-32-32z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
7
src/components/icons/windows.vue
Normal file
7
src/components/icons/windows.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 596 468"><path d="M170 74v64h64V74h288v192h-96v64h96c35 0 64-29 64-64V74c0-35-29-64-64-64H234c-35 0-64 29-64 64z" fill="var(--fill1)"/><path d="M74 138c-35 0-64 29-64 64v192c0 35 29 64 64 64h288c35 0 64-29 64-64V202c0-35-29-64-64-64H74zm24 80h240c13 0 24 11 24 24s-11 24-24 24H98c-13 0-24-11-24-24s11-24 24-24z" fill="var(--fill4)"/></svg>
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="596" height="468" viewBox="-10 -226 596 468"><path d="M512-184H224c-18 0-32 14-32 32v16h-32v-16c0-35 29-64 64-64h288c35 0 64 29 64 64V40c0 35-29 64-64 64h-48V72h48c18 0 32-14 32-32v-192c0-18-14-32-32-32zM352-56H64c-18 0-32 14-32 32V8h352v-32c0-18-14-32-32-32zm32 96H32v128c0 18 14 32 32 32h288c18 0 32-14 32-32V40zM64-88h288c35 0 64 29 64 64v192c0 35-29 64-64 64H64c-35 0-64-29-64-64V-24c0-35 29-64 64-64z" fill="currentColor"/></svg>
|
||||
</template>
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ filled?: boolean }>();
|
||||
</script>
|
||||
71
src/components/ui/AppButton.vue
Normal file
71
src/components/ui/AppButton.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cva } from "class-variance-authority";
|
||||
import { ButtonHTMLAttributes, computed } from 'vue';
|
||||
type UiButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
type UiButtonSize = 'sm' | 'md' | 'lg' | 'icon' | 'icon-sm' | 'icon-lg';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: UiButtonVariant;
|
||||
size?: UiButtonSize;
|
||||
block?: boolean;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
type?: 'button' | 'submit' | 'reset';
|
||||
onClick?: ButtonHTMLAttributes['onClick'];
|
||||
}>(),
|
||||
{
|
||||
variant: 'primary',
|
||||
size: 'md',
|
||||
block: false,
|
||||
disabled: false,
|
||||
loading: false,
|
||||
type: 'button',
|
||||
},
|
||||
);
|
||||
|
||||
const isDisabled = computed(() => props.disabled || props.loading);
|
||||
const buttonVariants = cva(":uno: inline-flex items-center justify-center gap-2 rounded-md border font-medium whitespace-nowrap shadow-[0_1px_0_rgba(27,31,36,0.04),0_1px_3px_rgba(27,31,36,0.12)] 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: {
|
||||
variant: {
|
||||
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',
|
||||
},
|
||||
size: {
|
||||
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]',
|
||||
icon: 'min-h-0 p-2',
|
||||
'icon-sm': 'min-h-0 p-1',
|
||||
'icon-lg': 'min-h-0 p-3',
|
||||
},
|
||||
block: {
|
||||
true: 'w-full',
|
||||
false: '',
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: props.variant,
|
||||
size: props.size,
|
||||
block: props.block,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button :type="type" :disabled="isDisabled" :class="cn(buttonVariants({variant, size, block}))" v-on:click="onClick" :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>
|
||||
47
src/components/ui/AppConfirmHost.vue
Normal file
47
src/components/ui/AppConfirmHost.vue
Normal 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>
|
||||
117
src/components/ui/AppDialog.vue
Normal file
117
src/components/ui/AppDialog.vue
Normal file
@@ -0,0 +1,117 @@
|
||||
<script setup lang="ts">
|
||||
import XIcon from '@/components/icons/XIcon.vue';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useTranslation } from 'i18next-vue';
|
||||
import { onBeforeUnmount, watch } from 'vue';
|
||||
import ClientOnly from '../ClientOnly';
|
||||
|
||||
// Ensure client-side only rendering to avoid hydration mismatch
|
||||
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) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
}
|
||||
else {
|
||||
document.body.style.overflow = 'unset';
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ClientOnly>
|
||||
<Teleport 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/40"
|
||||
@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-white 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="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 max-h-[80vh] overflow-y-auto">
|
||||
<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>
|
||||
</ClientOnly>
|
||||
</template>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user