11 Commits

Author SHA1 Message Date
2c66126ac9 using firebase auth 2026-01-15 17:36:26 +07:00
27bcb8bbef change architecture 2026-01-12 02:08:47 +07:00
f80ea881c6 feat: integrate Hono with NestJS and add logging middleware 2026-01-11 00:38:29 +07:00
e1e1d9cb7b chore: update dependencies and refactor application structure
- Bump AWS SDK packages to version 3.966.0
- Update @hono-di packages to version 0.0.15
- Update @types/node to version 25.0.5
- Refactor main application entry point to integrate NestJS with Hono
- Remove unused gRPC and user module files
- Add HonoAdapter for NestJS integration
- Implement basic AppController and AppService
- Configure Vite server to run on port 3000
2026-01-10 14:14:29 +07:00
3dcbbeacef chore: update dependencies and add Hono-Di integration
- Bump AWS SDK packages to version 3.965.0
- Add @hono-di/cli and @hono-di/core as dependencies
- Add Hono-Di Vite plugin to vite.config.ts
- Implement a new development tool for Hono-Di with file tree visualization
- Create user module with controller, service, and module files
- Enable experimental decorators and metadata in tsconfig.json
- Update main.ts to remove unnecessary console log
- Add UI for file tree visualizer in testDev plugin
2026-01-08 19:01:45 +07:00
800c8fd033 feat: Add classnames minifier transformer and update component styles 2026-01-06 18:42:14 +07:00
5fc4bef5be feat: Update Vite configuration and add new public routes
- Refactored vite.config.ts to update entry points and improve plugin configuration.
- Added Home.vue as the landing page with a modern design and features overview.
- Created Layout.vue for consistent header and footer across public routes.
- Implemented Privacy.vue and Terms.vue for legal documentation with structured content.
- Introduced gRPC and service index files for future service implementations.
- Developed createVueApp.ts for application setup with PrimeVue and Pinia.
- Enhanced SSR rendering in ssrRender.ts for improved performance and SEO.
2026-01-06 15:39:38 +07:00
518651a93e chore: update build process and add Docker support
- Modified build script in package.json to include minification.
- Removed unused dev server dependency from vite.config.ts.
- Added .dockerignore to exclude unnecessary files from Docker context.
- Created Dockerfile for multi-stage build with Bun for production deployment.
2026-01-05 23:14:09 +07:00
a7e865cc5a feat: Update dependencies and remove unused wrangler configuration 2026-01-05 23:04:10 +07:00
92608b52e2 feat: Update build scripts and configurations for client and server environments 2026-01-05 22:54:49 +07:00
0042fd1951 init 2026-01-05 19:13:03 +07:00
332 changed files with 7118 additions and 61300 deletions

View File

@@ -1,18 +0,0 @@
{
"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:*)"
]
}
}

View File

@@ -1,87 +0,0 @@
---
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"
STREAM_INTERNAL_AUTH_MARKER: "stream_maker_123xxx"
STREAM_UI_JWT_SECRET: "xxx_stream_maker_123_xxx"
STREAM_UI_REDIS_URL: "redis://:pass123@47.84.62.226:6379/3"
FRONTEND_BASE_URL: "https://hlstiktok.com"
---
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:
configMapKeyRef:
name: stream-ui-config
key: STREAM_INTERNAL_AUTH_MARKER
- name: STREAM_UI_JWT_SECRET
valueFrom:
configMapKeyRef:
name: stream-ui-config
key: STREAM_UI_JWT_SECRET
- name: STREAM_UI_REDIS_URL
valueFrom:
configMapKeyRef:
name: stream-ui-config
key: STREAM_UI_REDIS_URL
- name: FRONTEND_BASE_URL
valueFrom:
configMapKeyRef:
name: stream-ui-config
key: FRONTEND_BASE_URL

View File

@@ -9,8 +9,7 @@ yarn-error.log*
dist
build
.rsbuild
node_modules
/node_modules
# Environment files
.env
.env.local

374
AGENTS.md
View File

@@ -1,374 +0,0 @@
# AGENTS.md
This file provides guidance for AI coding agents working with the Holistream codebase.
hallo
## Project Overview
**Holistream** is a Vue 3 streaming application with Server-Side Rendering (SSR) deployed on Cloudflare Workers. It provides video upload, management, and streaming capabilities for content creators.
### Key Characteristics
- **Type**: Full-stack web application with SSR
- **Primary Language**: TypeScript
- **Package Manager**: Bun (evident from `bun.lock`)
- **Deployment Target**: Cloudflare Workers
## Technology Stack
| Category | Technology | Version |
|----------|------------|---------|
| Framework | Vue | 3.5.27 |
| Router | Vue Router | 5.0.2 |
| Server Framework | Hono | 4.11.7 |
| Build Tool | Vite | 7.3.1 |
| CSS Framework | UnoCSS | 66.6.0 |
| UI Components | PrimeVue | 4.5.4 |
| State Management | Pinia | 3.0.4 |
| Server State | Pinia Colada | 0.21.2 |
| Meta/SEO | @unhead/vue | 2.1.2 |
| Utilities | VueUse | 14.2.0 |
| Validation | Zod | 4.3.6 |
| Deployment | Wrangler | 4.62.0 |
## Project Structure
```
.
├── src/
│ ├── api/ # API client and HTTP adapters
│ │ ├── client.ts # Auto-generated API client from OpenAPI spec
│ │ ├── httpClientAdapter.client.ts # Client-side fetch adapter
│ │ └── httpClientAdapter.server.ts # Server-side fetch adapter
│ ├── client.ts # Client entry point (hydration)
│ ├── components/ # Vue components
│ │ ├── dashboard/ # Dashboard-specific components
│ │ ├── icons/ # Custom icon components
│ │ ├── ui/ # UI primitive components
│ │ ├── ClientOnly.tsx # SSR-safe client-only wrapper
│ │ ├── DashboardLayout.vue # Main dashboard layout
│ │ ├── GlobalUploadIndicator.vue
│ │ ├── NotificationDrawer.vue
│ │ └── RootLayout.vue # Root application layout
│ ├── composables/ # Vue composables
│ │ └── useUploadQueue.ts # Upload queue management
│ ├── index.tsx # Server entry point (Hono app)
│ ├── lib/ # Utility libraries
│ │ ├── constants.ts # Application constants
│ │ ├── directives/ # Custom Vue directives
│ │ ├── hoc/ # Higher-order components
│ │ ├── interface.ts # TypeScript interfaces
│ │ ├── liteMqtt.ts # MQTT client (browser)
│ │ ├── manifest.ts # Vite manifest utilities
│ │ ├── PiniaSharedState.ts # Pinia state hydration plugin
│ │ ├── primePassthrough.ts # PrimeVue theme configuration
│ │ ├── replateStreamText.ts
│ │ └── utils.ts # Utility functions (cn, formatters, etc.)
│ ├── main.ts # App factory function
│ ├── mocks/ # Mock data for development
│ ├── routes/ # Route components (page components)
│ │ ├── auth/ # Authentication pages
│ │ ├── home/ # Public pages (landing, terms, privacy)
│ │ ├── notification/ # Notification page
│ │ ├── overview/ # Dashboard overview
│ │ ├── plans/ # Payments & plans
│ │ ├── profile/ # User profile
│ │ ├── upload/ # Video upload
│ │ ├── video/ # Video management
│ │ ├── index.ts # Router configuration
│ │ └── NotFound.vue # 404 page
│ ├── server/ # Server-side utilities
│ │ └── modules/
│ │ └── merge.ts # Video chunk merge logic
│ ├── stores/ # Pinia stores
│ │ └── auth.ts # Authentication store
│ ├── type.d.ts # TypeScript declarations
│ └── worker/ # Worker utilities
│ ├── html.ts
│ └── ssrLayout.ts
├── bootstrap_btn.ts # Bootstrap button preset for UnoCSS
├── ssrPlugin.ts # Custom Vite SSR plugin
├── uno.config.ts # UnoCSS configuration
├── vite.config.ts # Vite configuration
├── wrangler.jsonc # Cloudflare Workers configuration
├── tsconfig.json # TypeScript configuration
├── package.json # Package dependencies
├── bun.lock # Bun lock file
├── docs.json # OpenAPI/Swagger spec for API
├── auto-imports.d.ts # Auto-generated type declarations
└── components.d.ts # Auto-generated component declarations
```
## Build and Development Commands
```bash
# Install dependencies
bun install
# Start development server with hot reload
bun dev
# Build for production (client + worker)
bun run build
# Preview production build locally
bun preview
# Deploy to Cloudflare Workers
bun run deploy
# Generate TypeScript types from Wrangler config
bun run cf-typegen
# View Cloudflare Worker logs
bun run tail
```
> **Note**: While npm commands work (`npm run dev`, etc.), the project uses Bun as its primary package manager.
## Architecture Details
### SSR Architecture
The application uses a custom SSR setup defined in `ssrPlugin.ts`:
1. **Build Order**: Client bundle is built FIRST, then the Worker bundle
2. **Manifest Injection**: Vite manifest is injected into the server build for asset rendering
3. **Environment-based Resolution**: `httpClientAdapter` and `liteMqtt` resolve to different implementations based on SSR context
**Entry Points:**
- **Server**: `src/index.tsx` - Hono app that renders Vue SSR stream
- **Client**: `src/client.ts` - Hydrates the SSR-rendered application
- **App Factory**: `src/main.ts` - Creates the Vue app instance (used by both)
### State Management with SSR
Uses **Pinia Colada** for server state with SSR hydration:
- Server-side queries are fetched and serialized to `window.__APP_DATA__`
- Client hydrates the query cache via `hydrateQueryCache()`
- Pinia state is serialized and restored via `PiniaSharedState` plugin
### Module Aliases
Configured in `tsconfig.json` and `vite.config.ts`:
| Alias | Resolution |
|-------|------------|
| `@/` | `src/` |
| `@httpClientAdapter` | `src/api/httpClientAdapter.server.ts` (SSR) or `.client.ts` (browser) |
| `@liteMqtt` | `src/lib/liteMqtt.server.ts` (SSR) or `.ts` (browser) |
### API Client Architecture
The API client (`src/api/client.ts`) is **auto-generated** from the OpenAPI spec (`docs.json`):
- Uses `customFetch` adapter that differs between client/server
- **Server adapter** (`httpClientAdapter.server.ts`): Forwards cookies, merges headers, proxies to `api.pipic.fun`
- **Client adapter** (`httpClientAdapter.client.ts`): Standard fetch with credentials
- API proxy route: `/r/*` paths proxy to `https://api.pipic.fun`
### Routing Structure
Routes are defined in `src/routes/index.ts` with three main layout groups:
1. **Public** (`/`): Landing page, terms, privacy
2. **Auth** (`/login`, `/sign-up`, `/forgot`): Authentication pages (redirects if logged in)
3. **Dashboard**: Protected routes requiring authentication
- `/overview` - Main dashboard
- `/upload` - Video upload with queue management
- `/video` - Video list
- `/video/:id` - Video detail/edit
- `/payments-and-plans` - Billing management
- `/notification`, `/profile` - User settings
Route meta supports `@unhead/vue` for SEO:
```ts
meta: {
head: {
title: "Page Title",
meta: [{ name: "description", content: "..." }]
}
}
```
### Styling System (UnoCSS)
Configuration in `uno.config.ts`:
- **Presets**: Wind4 (Tailwind-like), Typography, Attributify, Bootstrap buttons
- **Custom Colors**:
- `primary` (#14a74b)
- `secondary` (#fd7906)
- `accent`, `success`, `info`, `warning`, `danger`
- **Shortcuts**: `press-animated` for button press effects
- **Transformers**:
- `transformerCompileClass` (prefix: `_` for compiled classes)
- `transformerVariantGroup`
Use `cn()` from `src/lib/utils.ts` for conditional class merging (combines `clsx` + `tailwind-merge`).
### Component Auto-Import
Components are auto-imported via `unplugin-vue-components`:
- PrimeVue components resolved via `PrimeVueResolver`
- Vue/Pinia/Vue Router APIs auto-imported via `unplugin-auto-import`
- Type declarations auto-generated to `components.d.ts` and `auto-imports.d.ts`
## Development Guidelines
### Code Style
- **TypeScript**: Strict mode enabled
- **JSX/TSX**: Supported for components (import source: `vue`)
- **CSS**: Use UnoCSS utility classes; custom CSS in component `<style>` blocks when needed
### File Organization
- Page components go in `src/routes/` following the route structure
- Reusable components go in `src/components/`
- Composables go in `src/composables/`
- Stores go in `src/stores/`
- Server utilities go in `src/server/`
### HTTP Requests
**Always use the generated API client** instead of raw fetch:
```ts
import { client } from '@/api/client';
// Example
const response = await client.auth.loginCreate({ email, password });
```
The client handles:
- Base URL resolution
- Cookie forwarding (server-side)
- Type safety
### Authentication Flow
- `useAuthStore` manages auth state with cookie-based sessions
- `init()` is called on every request to fetch current user via `/me` endpoint
- `beforeEach` router guard redirects unauthenticated users from protected routes
- MQTT client connects on user login for real-time notifications
### File Upload Architecture
Upload queue (`src/composables/useUploadQueue.ts`):
- Supports both local files and remote URLs
- Presigned POST URLs fetched from API
- Parallel chunk upload (90MB chunks, max 3 parallel)
- Progress tracking with speed calculation
### Type Safety
- TypeScript strict mode enabled
- `CloudflareBindings` interface for environment variables (generated via `cf-typegen`)
- API types auto-generated from backend OpenAPI spec (`docs.json`)
## Environment Configuration
### Cloudflare Worker Bindings
Configured in `wrangler.jsonc`:
```json
{
"name": "holistream",
"compatibility_date": "2025-08-03",
"compatibility_flags": ["nodejs_compat"],
"observability": { ... }
}
```
- No explicit secrets in code - use Wrangler secrets management
- Access environment variables via Hono context: `c.env.VAR_NAME`
### Local Environment
Create `.dev.vars` for local development secrets (do not commit):
```
SECRET_KEY=...
```
## Testing and Quality
**Current Status**: There are currently no automated test suites (like Vitest) or linting tools (like ESLint/Prettier) configured.
When adding tests or linting:
- Add appropriate dev dependencies
- Update this section with commands and conventions
- Consider the SSR environment when writing tests
## Security Considerations
1. **Cookie Security**: Cookies are httpOnly, secure, and sameSite
2. **CORS**: Configured via Hono's CORS middleware
3. **API Proxy**: Backend API is never exposed directly to the browser; all requests go through `/r/*` proxy
4. **Input Validation**: Use Zod for runtime validation
5. **XSS Protection**: HTML escaping is applied to SSR data via `htmlEscape()` function
## Common Patterns
### Creating a New Page
1. Create component in `src/routes/<section>/PageName.vue`
2. Add route to `src/routes/index.ts` with appropriate meta
3. Use `head` in route meta for SEO if needed
### Using the Upload Queue
```ts
import { useUploadQueue } from '@/composables/useUploadQueue';
const { items, addFiles, addRemoteUrls, startQueue } = useUploadQueue();
```
### Accessing Hono Context in Components
```ts
import { inject } from 'vue';
const honoContext = inject('honoContext');
```
### Conditional Classes
```ts
import { cn } from '@/lib/utils';
const className = cn(
'base-class',
isActive && 'active-class',
variant === 'primary' ? 'text-primary' : 'text-secondary'
);
```
## External Dependencies
- **Backend API**: `https://api.pipic.fun`
- **MQTT Broker**: `wss://mqtt-dashboard.com:8884/mqtt`
- **Fonts**: Google Fonts (Google Sans loaded from fonts.googleapis.com)
## Important Files Reference
| Purpose | Path |
|---------|------|
| Server entry | `src/index.tsx` |
| Client entry | `src/client.ts` |
| App factory | `src/main.ts` |
| Router config | `src/routes/index.ts` |
| API client | `src/api/client.ts` |
| Auth store | `src/stores/auth.ts` |
| SSR plugin | `ssrPlugin.ts` |
| UnoCSS config | `uno.config.ts` |
| Wrangler config | `wrangler.jsonc` |
| Vite config | `vite.config.ts` |
---
*This document was generated for AI coding agents. For human contributors, see README.md.*

View File

@@ -1,83 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
`stream-ui` is a Vue 3 SSR frontend deployed on Cloudflare Workers. It uses Hono as the Worker server layer and a custom Vite SSR setup rather than Nuxt.
## Common commands
Run all commands from `stream-ui/`.
```bash
# Install dependencies
bun install
# Start local dev server
bun run dev
# Build client + worker bundles
bun run build
# Preview production build locally
bun run preview
# Deploy to Cloudflare Workers
bun run deploy
# Regenerate Cloudflare binding types from Wrangler config
bun run cf-typegen
# Tail Cloudflare Worker logs
bun run tail
```
Notes:
- This project uses Bun (`bun.lock` is present).
- There is currently no configured `test` script.
- There is currently no configured `lint` script.
## Architecture
### SSR entrypoints
- `src/index.tsx`: Hono Worker entry; registers middleware, proxy routes, merge/display/manifest routes, then SSR routes
- `src/main.ts`: shared app factory for SSR and client hydration
- `src/client.ts`: client-side hydration entry
- `ssrPlugin.ts`: custom Vite SSR plugin that builds the client first, injects the Vite manifest, and swaps environment-specific modules
### Routing and app structure
- Routes live in `src/routes/index.ts`.
- Routing is SSR-aware: `createMemoryHistory()` on the server and `createWebHistory()` in the browser.
- The app is split into:
- public pages
- auth pages
- protected dashboard/settings pages
- Current protected areas include `videos`, `notification`, and `settings/*` routes.
### State and hydration
- Pinia is used for app state.
- `@pinia/colada` is used for server-state/query hydration.
- SSR serializes Pinia state into `$p` and query cache into `$colada`; `src/client.ts` restores both during hydration.
- `src/stores/auth.ts` owns session state and route guards depend on `auth.user`.
### API integration
- `src/api/client.ts` is generated by `swagger-typescript-api`; do not hand-edit generated sections.
- API access should go through the generated client and `@httpClientAdapter`, not raw `fetch`.
- `src/api/httpClientAdapter.server.ts` handles SSR-side API calls by forwarding request headers/cookies and proxying frontend `/r/*` requests to `https://api.pipic.fun`.
- `src/api/httpClientAdapter.client.ts` is the browser-side adapter.
### Notable flows
- `src/stores/auth.ts` initializes the logged-in user from `/me` and opens an MQTT connection after login.
- `src/composables/useUploadQueue.ts` implements the custom upload queue:
- 90MB chunks
- max 3 parallel uploads
- max 3 retries
- max 5 queued items
- Styling uses UnoCSS (`uno.config.ts`).
## Important notes
- Prefer the actual current code over older documentation when they conflict.
- The previous version of this file contained stale route and dependency details; verify against `src/routes/index.ts` and `package.json` before assuming old pages or libraries still exist.
- Any frontend change that affects API contracts should be checked against the backend repo (`../stream.api`) as well.

View File

@@ -1,5 +1,5 @@
# ---------- Builder stage ----------
FROM oven/bun:1.3.10-alpine AS builder
FROM oven/bun:1.3.5-alpine AS builder
WORKDIR /app
@@ -20,7 +20,7 @@ RUN bun run build
# ---------- Production stage ----------
FROM oven/bun:1.3.10-alpine AS production
FROM oven/bun:1.3.5-alpine AS production
WORKDIR /app

152
build.ts Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bun
import { existsSync } from "fs";
import { rm } from "fs/promises";
import path from "path";
if (process.argv.includes("--help") || process.argv.includes("-h")) {
console.log(`
🏗️ Bun Build Script
Usage: bun run build.ts [options]
Common Options:
--outdir <path> Output directory (default: "dist")
--minify Enable minification (or --minify.whitespace, --minify.syntax, etc)
--sourcemap <type> Sourcemap type: none|linked|inline|external
--target <target> Build target: browser|bun|node
--format <format> Output format: esm|cjs|iife
--splitting Enable code splitting
--packages <type> Package handling: bundle|external
--public-path <path> Public path for assets
--env <mode> Environment handling: inline|disable|prefix*
--conditions <list> Package.json export conditions (comma separated)
--external <list> External packages (comma separated)
--banner <text> Add banner text to output
--footer <text> Add footer text to output
--define <obj> Define global constants (e.g. --define.VERSION=1.0.0)
--help, -h Show this help message
Example:
bun run build.ts --outdir=dist --minify --sourcemap=linked --external=react,react-dom
`);
process.exit(0);
}
const toCamelCase = (str: string): string => str.replace(/-([a-z])/g, g => g[1].toUpperCase());
const parseValue = (value: string): any => {
if (value === "true") return true;
if (value === "false") return false;
if (/^\d+$/.test(value)) return parseInt(value, 10);
if (/^\d*\.\d+$/.test(value)) return parseFloat(value);
if (value.includes(",")) return value.split(",").map(v => v.trim());
return value;
};
function parseArgs(): Partial<Bun.BuildConfig> & { [key: string]: any } {
const config: Partial<Bun.BuildConfig> & { [key: string]: any } = {};
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === undefined) continue;
if (!arg.startsWith("--")) continue;
if (arg.startsWith("--no-")) {
const key = toCamelCase(arg.slice(5));
config[key] = false;
continue;
}
if (!arg.includes("=") && (i === args.length - 1 || args[i + 1]?.startsWith("--"))) {
const key = toCamelCase(arg.slice(2));
config[key] = true;
continue;
}
let key: string;
let value: string;
if (arg.includes("=")) {
[key, value] = arg.slice(2).split("=", 2) as [string, string];
} else {
key = arg.slice(2);
value = args[++i] ?? "";
}
key = toCamelCase(key);
if (key.includes(".")) {
const [parentKey, childKey] = key.split(".");
config[parentKey] = config[parentKey] || {};
config[parentKey][childKey] = parseValue(value);
} else {
config[key] = parseValue(value);
}
}
return config;
}
const formatFileSize = (bytes: number): string => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(2)} ${units[unitIndex]}`;
};
console.log("\n🚀 Starting build process...\n");
const cliConfig = parseArgs();
const outdir = cliConfig.outdir || path.join(process.cwd(), "dist");
// if (existsSync(outdir)) {
// console.log(`🗑️ Cleaning previous build at ${outdir}`);
// await rm(outdir, { recursive: true, force: true });
// }
const start = performance.now();
// const entrypoints = [...new Bun.Glob("**.html").scanSync("src")]
// .map(a => path.resolve("src", a))
// .filter(dir => !dir.includes("node_modules"));
const entrypoints = [path.resolve("dist/server/index.js")];
console.log(`📄 Found ${entrypoints.length} HTML ${entrypoints.length === 1 ? "file" : "files"} to process\n`);
const result = await Bun.build({
entrypoints,
outdir,
plugins: [],
minify: false,
// target: "browser",
target: "bun",
// sourcemap: "linked",
sourcemap: false,
define: {
"process.env.NODE_ENV": JSON.stringify("production"),
},
external: ["@nestjs/microservices",'@nestjs/platform-express', "@nestjs/websockets", "class-transformer", "class-validator"],
...cliConfig,
});
const end = performance.now();
const outputTable = result.outputs.map(output => ({
File: path.relative(process.cwd(), output.path),
Type: output.kind,
Size: formatFileSize(output.size),
}));
console.table(outputTable);
const buildTime = (end - start).toFixed(2);
console.log(`\n✅ Build completed in ${buildTime}ms\n`);

962
bun.lock

File diff suppressed because it is too large Load Diff

206
components.d.ts vendored
View File

@@ -12,185 +12,47 @@ export {}
/* prettier-ignore */
declare module 'vue' {
export interface GlobalComponents {
ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
Add: typeof import('./src/components/icons/Add.vue')['default']
AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
AppButton: typeof import('./src/components/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']
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']
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']
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']
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']
Add: typeof import('./src/client/components/icons/Add.vue')['default']
Bell: typeof import('./src/client/components/icons/Bell.vue')['default']
Button: typeof import('primevue/button')['default']
Checkbox: typeof import('primevue/checkbox')['default']
CheckIcon: typeof import('./src/client/components/icons/CheckIcon.vue')['default']
Credit: typeof import('./src/client/components/icons/Credit.vue')['default']
DashboardLayout: typeof import('./src/client/components/DashboardLayout.vue')['default']
Home: typeof import('./src/client/components/icons/Home.vue')['default']
InputText: typeof import('primevue/inputtext')['default']
Layout: typeof import('./src/client/components/icons/Layout.vue')['default']
Message: typeof import('primevue/message')['default']
Password: typeof import('primevue/password')['default']
RootLayout: typeof import('./src/client/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']
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']
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']
TestIcon: typeof import('./src/client/components/icons/TestIcon.vue')['default']
Upload: typeof import('./src/client/components/icons/Upload.vue')['default']
Video: typeof import('./src/client/components/icons/Video.vue')['default']
VueHead: typeof import('./src/client/components/VueHead.tsx')['default']
}
}
// For TSX support
declare global {
const ActivityIcon: typeof import('./src/components/icons/ActivityIcon.vue')['default']
const Add: typeof import('./src/components/icons/Add.vue')['default']
const AdvertisementIcon: typeof import('./src/components/icons/AdvertisementIcon.vue')['default']
const AlertTriangle: typeof import('./src/components/icons/AlertTriangle.vue')['default']
const AlertTriangleIcon: typeof import('./src/components/icons/AlertTriangleIcon.vue')['default']
const AppButton: typeof import('./src/components/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 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 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 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 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 Add: typeof import('./src/client/components/icons/Add.vue')['default']
const Bell: typeof import('./src/client/components/icons/Bell.vue')['default']
const Button: typeof import('primevue/button')['default']
const Checkbox: typeof import('primevue/checkbox')['default']
const CheckIcon: typeof import('./src/client/components/icons/CheckIcon.vue')['default']
const Credit: typeof import('./src/client/components/icons/Credit.vue')['default']
const DashboardLayout: typeof import('./src/client/components/DashboardLayout.vue')['default']
const Home: typeof import('./src/client/components/icons/Home.vue')['default']
const InputText: typeof import('primevue/inputtext')['default']
const Layout: typeof import('./src/client/components/icons/Layout.vue')['default']
const Message: typeof import('primevue/message')['default']
const Password: typeof import('primevue/password')['default']
const RootLayout: typeof import('./src/client/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 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 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']
const TestIcon: typeof import('./src/client/components/icons/TestIcon.vue')['default']
const Upload: typeof import('./src/client/components/icons/Upload.vue')['default']
const Video: typeof import('./src/client/components/icons/Video.vue')['default']
const VueHead: typeof import('./src/client/components/VueHead.tsx')['default']
}

View File

@@ -1,21 +0,0 @@
curl --request POST \
--url https://api.github.com/repos/lethdat09/builder/dispatches \
--header 'accept: */*' \
--header 'authorization: Bearer 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' \
--header 'content-type: application/json' \
--header 'user-agent: Thunder Client (https://www.thunderclient.io)' \
--data '{
"event_type": "trigger_build",
"client_payload": {
"gitUrl": "https://git.inet.io.vn/stream/stream.ui.git",
"branch": "develop-updateui",
"imageName": "stream123/stream.ui",
"dockerfilePath": "Dockerfile",
"kubeConfigYamlPath": ".deploy/stream.ui-production.yaml",
"kubeConfig": "YXBpVmVyc2lvbjogdjEKY2x1c3RlcnM6Ci0gY2x1c3RlcjoKICAgIGNlcnRpZmljYXRlLWF1dGhvcml0eS1kYXRhOiBMUzB0TFMxQ1JVZEpUaUJEUlZKVVNVWkpRMEZVUlMwdExTMHRDazFKU1VKa2VrTkRRVkl5WjBGM1NVSkJaMGxDUVVSQlMwSm5aM0ZvYTJwUFVGRlJSRUZxUVdwTlUwVjNTSGRaUkZaUlVVUkVRbWh5VFROTmRHTXlWbmtLWkcxV2VVeFhUbWhSUkVVelRucFJOVTVFU1RCT2VrMTNTR2hqVGsxcVdYZE5lazE0VFVSWmVrNUVUWHBYYUdOT1RYcFpkMDE2U1RSTlJGbDZUa1JOZWdwWGFrRnFUVk5GZDBoM1dVUldVVkZFUkVKb2NrMHpUWFJqTWxaNVpHMVdlVXhYVG1oUlJFVXpUbnBSTlU1RVNUQk9lazEzVjFSQlZFSm5ZM0ZvYTJwUENsQlJTVUpDWjJkeGFHdHFUMUJSVFVKQ2QwNURRVUZUWm5sUVRHaE5kVEJvZFVNelpFb3JlbFZHV0ZVNVYyMHdLM1YxVUhSUFVVTjRSMFZSYkV4ak9Wa0tZV3RsYm1kc1JFZDRTRGs1UjBKcFRFOHlka1pZTm5oalZYcFdka040T0U0NFpqWm9NREpFZVZJNFJGTnZNRWwzVVVSQlQwSm5UbFpJVVRoQ1FXWTRSUXBDUVUxRFFYRlJkMFIzV1VSV1VqQlVRVkZJTDBKQlZYZEJkMFZDTDNwQlpFSm5UbFpJVVRSRlJtZFJWVWhuUTFGWFVVNHlLM1p4TlZKWmRFcFdVVEJNQ2toa00xVlhkMGwzUTJkWlNVdHZXa2w2YWpCRlFYZEpSRk5CUVhkU1VVbG5SbXBDU1hoMFFUVXdRMmwyZFdoVVUzbFZRalpqYjBSU2FWWjBWVVYzUVZrS2VYWjZXRGxHUm5CcVl6aERTVkZFUzBGVFNrZFBaRUZXUW01TmJsRTNWa3BpVVVkWldFRlJSMjlwTmpCRlpuZzVZMUprWTA5UVJWQTFkejA5Q2kwdExTMHRSVTVFSUVORlVsUkpSa2xEUVZSRkxTMHRMUzBLCiAgICBzZXJ2ZXI6IGh0dHBzOi8vNDIuOTYuMTUuMTA5OjY0NDMKICBuYW1lOiBkZWZhdWx0CmNvbnRleHRzOgotIGNvbnRleHQ6CiAgICBjbHVzdGVyOiBkZWZhdWx0CiAgICB1c2VyOiBkZWZhdWx0CiAgbmFtZTogZGVmYXVsdApjdXJyZW50LWNvbnRleHQ6IGRlZmF1bHQKa2luZDogQ29uZmlnCnVzZXJzOgotIG5hbWU6IGRlZmF1bHQKICB1c2VyOgogICAgY2xpZW50LWNlcnRpZmljYXRlLWRhdGE6IExTMHRMUzFDUlVkSlRpQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENrMUpTVUpyUkVORFFWUmxaMEYzU1VKQlowbEpabG95WW10NGJVRTFTRFIzUTJkWlNVdHZXa2w2YWpCRlFYZEpkMGw2UldoTlFqaEhRVEZWUlVGM2Qxa0tZWHBPZWt4WFRuTmhWMVoxWkVNeGFsbFZRWGhPZW1Nd1QxUlJlVTVFWTNwTlFqUllSRlJKTWsxRVRYcE5WRUV5VFhwUmVrMHhiMWhFVkVrelRVUk5lZ3BOVkVFeVRYcFJlazB4YjNkTlJFVllUVUpWUjBFeFZVVkRhRTFQWXpOc2VtUkhWblJQYlRGb1l6TlNiR051VFhoR1ZFRlVRbWRPVmtKQlRWUkVTRTQxQ21NelVteGlWSEJvV2tjeGNHSnFRbHBOUWsxSFFubHhSMU5OTkRsQlowVkhRME54UjFOTk5EbEJkMFZJUVRCSlFVSkZjVE0wUWl0U05tdEVXVzlQY213S1dTdDFiMFpTTjBOdFJUTTVVRk54U1hNeGFYWllWak5aVjBoUmF6bHdSVlpZUm5GWVpYQnZXVmg0TW5KM1pVbFlTVEZTY1dGSU9TdHJPVGw0WkM5c1FRb3ZZamxRWm14UGFsTkVRa2ROUVRSSFFURlZaRVIzUlVJdmQxRkZRWGRKUm05RVFWUkNaMDVXU0ZOVlJVUkVRVXRDWjJkeVFtZEZSa0pSWTBSQmFrRm1Da0puVGxaSVUwMUZSMFJCVjJkQ1V6azFRa0pRWWxWT2MwaFljeXR6WXpoTmNWaHJZWEF3VVhscFJFRkxRbWRuY1docmFrOVFVVkZFUVdkT1NFRkVRa1VLUVdsQ1RXZFJVVGRaY0c5WlMwcDNiMFIyVTBNMlMwVnhaM0VyTkZWTkt6Vkxja2hVV0d0UVFuRTBVazFrUVVsblF6SmhPV0owZDNwdGMwUkZZVFpKVWdwNmRucFpOUzlLUjBKRVZrOUNkM28wV0ZNNU0xaFVkR2h0UW5jOUNpMHRMUzB0UlU1RUlFTkZVbFJKUmtsRFFWUkZMUzB0TFMwS0xTMHRMUzFDUlVkSlRpQkRSVkpVU1VaSlEwRlVSUzB0TFMwdENrMUpTVUpsUkVORFFWSXlaMEYzU1VKQlowbENRVVJCUzBKblozRm9hMnBQVUZGUlJFRnFRV3BOVTBWM1NIZFpSRlpSVVVSRVFtaHlUVE5OZEZreWVIQUtXbGMxTUV4WFRtaFJSRVV6VG5wUk5VNUVTVEJPZWsxM1NHaGpUazFxV1hkTmVrMTRUVVJaZWs1RVRYcFhhR05PVFhwWmQwMTZTVFJOUkZsNlRrUk5lZ3BYYWtGcVRWTkZkMGgzV1VSV1VWRkVSRUpvY2swelRYUlpNbmh3V2xjMU1FeFhUbWhSUkVVelRucFJOVTVFU1RCT2VrMTNWMVJCVkVKblkzRm9hMnBQQ2xCUlNVSkNaMmR4YUd0cVQxQlJUVUpDZDA1RFFVRlNabTFRYTBaT1pYTnNhV05aZFhSUGJrVmtVbmN2S3pCVE0yVkxkSGNyU0ZwbmJIcFVRazF3WVdrS2RYQjFXWFJuVmpad2IwdG9kSGhUYVhFdk5rWktRa0owZWtoSlNsSjRUMlp0V1RnemVtaENVbE5oUlZOdk1FbDNVVVJCVDBKblRsWklVVGhDUVdZNFJRcENRVTFEUVhGUmQwUjNXVVJXVWpCVVFWRklMMEpCVlhkQmQwVkNMM3BCWkVKblRsWklVVFJGUm1kUlZYWmxVVkZVTWpGRVlrSXhOMUJ5U0ZCRVMydzFDa2R4WkVWTmIyZDNRMmRaU1V0dldrbDZhakJGUVhkSlJGTlJRWGRTWjBsb1FVb3pNVVJWTUhSaFRHVnNWVFJpUVcxUlRYSnJNMEpvT0doSWNuUTNhamtLYkRka1p6YzFhelJ5Vlc1MVFXbEZRWGhsVDFCaFVVUTBTWHBNYzBwVmRITkpOWGRWUzBoUFZWTnFWblE1U20xVWMwSTRTVnB0TTBOM1lXODlDaTB0TFMwdFJVNUVJRU5GVWxSSlJrbERRVlJGTFMwdExTMEsKICAgIGNsaWVudC1rZXktZGF0YTogTFMwdExTMUNSVWRKVGlCRlF5QlFVa2xXUVZSRklFdEZXUzB0TFMwdENrMUlZME5CVVVWRlNVTk9hVlp2VG1KVGRHZEJWSEJzT1ZSTlpWbHlOMHBUWkVoRk5qZElWMWxrWkZOc05UTmFSbFpUZEhodlFXOUhRME54UjFOTk5Ea0tRWGRGU0c5VlVVUlJaMEZGVTNKbVowZzFTSEZSVG1sbk5uVldhalkyWjFaSWMwdFpWR1l3T1V0dmFYcFhTemxrV0dSb1dXUkRWREpyVWxaalYzQmtOZ3B0YUdobVNHRjJRalJvWTJwV1IzQnZaak0yVkRNelJqTXJWVVE1ZGpBNUsxVjNQVDBLTFMwdExTMUZUa1FnUlVNZ1VGSkpWa0ZVUlNCTFJWa3RMUzB0TFFvPQo=",
"quay_username": "lethdat",
"quay_token": "htK3xi1/mQdOSQyBxbGVr9Hhpm/ywzNGawjk29lNHZcRXRdec7kc1v9LRE6X1ATE",
"telegram_chat_id": "-4891576755",
"tele_token": "8230541188:AAGNu6-2iBaFu2JkvORtXM9c6dUZQdQdqYU"
}
}'

3731
docs.json

File diff suppressed because it is too large Load Diff

View File

@@ -2,47 +2,55 @@
"name": "holistream",
"type": "module",
"scripts": {
"dev": "bun x --bun vite",
"build": "bun x --bun vite build && bun build dist/server/index.js --target=bun --outfile dist/index.js",
"preview": "bun x --bun vite preview"
"dev": "bunx --bun vite",
"build": "bunx --bun vite build && bun run build.ts",
"preview": "bunx --bun vite preview"
},
"dependencies": {
"@bufbuild/protobuf": "^2.11.0",
"@grpc/grpc-js": "^1.14.3",
"@hattip/adapter-node": "^0.0.49",
"@aws-sdk/client-s3": "^3.966.0",
"@aws-sdk/s3-presigned-post": "^3.966.0",
"@aws-sdk/s3-request-presigner": "^3.966.0",
"@hiogawa/tiny-rpc": "^0.2.3-pre.18",
"@hiogawa/utils": "^1.7.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",
"@nestjs/common": "^11.1.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.11",
"@nestjs/jwt": "^11.0.2",
"@nestjs/passport": "^11.0.5",
"@primeuix/themes": "^2.0.2",
"@primevue/forms": "^4.5.4",
"@unhead/vue": "^2.1.1",
"@vueuse/core": "^14.1.0",
"clsx": "^2.1.1",
"hono": "^4.12.9",
"i18next": "^26.0.3",
"i18next-http-backend": "^3.0.4",
"i18next-vue": "^5.4.0",
"firebase": "^12.8.0",
"hono": "^4.11.3",
"is-mobile": "^5.0.0",
"nestjs-zod": "^5.1.1",
"passport-google-oauth20": "^2.0.0",
"passport-jwt": "^4.0.1",
"pinia": "^3.0.4",
"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"
"primevue": "^4.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.26",
"vue-router": "^4.6.4",
"zod": "^4.3.5"
},
"devDependencies": {
"@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",
"@hattip/adapter-node": "^0.0.49",
"@hono/node-server": "^1.19.8",
"@primevue/auto-import-resolver": "^4.5.4",
"@types/bun": "^1.3.5",
"@types/node": "^25.0.5",
"@types/passport-google-oauth20": "^2.0.17",
"@types/passport-jwt": "^4.0.1",
"@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.1",
"vite-ssr-components": "^0.5.2"
}
}
}

View File

@@ -0,0 +1,107 @@
// import type { SourceCodeTransformer } from '@unocss/core'
// import { escapeRegExp, expandVariantGroup } from '@unocss/core'
import { SourceCodeTransformer, escapeRegExp, expandVariantGroup } from 'unocss'
export const defaultChars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
export function charCombinations(chars: string = defaultChars) {
const combination = [-1]
const charsLastIdx = chars.length - 1
const resetFromIndex = (idx: number) => {
for (let i = idx; i < combination.length; i++)
combination[i] = 0
}
return () => {
for (let i = combination.length - 1; i >= 0; i--) {
if (combination[i] !== charsLastIdx) {
combination[i] += 1
resetFromIndex(i + 1)
break
}
if (i === 0) {
resetFromIndex(0)
combination.push(0)
break
}
}
return "_"+combination.map(i => chars[i]).join('')
}
}
export interface CompileClassOptions {
/**
* Special prefix to avoid UnoCSS transforming your code.
* @default ':uno:'
*/
trigger?: string
/**
* Hash function
*/
hashFn?: () => string
/**
* The layer name of generated rules
*/
layer?: string
}
export default function transformerClassnamesMinifier(options: CompileClassOptions = {}): SourceCodeTransformer {
const {
trigger = ':uno:',
hashFn = charCombinations(),
} = options
const compiledClass = new Map()
const regexp = RegExp(`(["'\`])${escapeRegExp(trigger)}${trigger ? '\\s' : ''}(.*?)\\1`, 'g')
return {
name: 'name',
enforce: 'pre',
async transform(s, _id, { uno }) {
if(s.original.includes('p-button') || s.original.includes('p-component') || s.original.includes('p-button-secondary')) {
}
const matches = [...s.original.matchAll(regexp)]
if (!matches.length)
return
// console.log("s.original", s.original)
for (const match of matches) {
const body = match.length ? expandVariantGroup(match[2].trim()) : ''
const start = match.index!
const replacements = []
const result = await Promise.all(body.split(/\s+/).filter(Boolean).map(async i => [i, !!await uno.parseToken(i)] as const))
const known = result.filter(([, matched]) => matched).map(([i]) => i)
const unknown = result.filter(([, matched]) => !matched).map(([i]) => i)
replacements.push(...unknown)
known.forEach((i) => {
const compiled = compiledClass.get(i)
if (compiled)
return replacements.push(compiled)
const className = hashFn()
compiledClass.set(i, className)
if (options.layer)
uno.config.shortcuts.push([className, i, { layer: options.layer }])
else
uno.config.shortcuts.push([className, i])
replacements.push(className)
})
s.overwrite(start + 1, start + match[0].length - 1, replacements.join(' '))
}
},
}
}

View File

@@ -115,8 +115,8 @@ export default function ssrPlugin(): Plugin[] {
return path.resolve(
__dirname,
options?.ssr
? pwd+"/src/api/httpClientAdapter.server.ts"
: pwd+"/src/api/httpClientAdapter.client.ts"
? pwd+"/src/client/api/httpClientAdapter.server.ts"
: pwd+"/src/client/api/httpClientAdapter.client.ts"
);
},
async configResolved(config) {
@@ -152,4 +152,4 @@ export default function ssrPlugin(): Plugin[] {
plugins.push(injectManifest());
return plugins;
}
}

64
plugins/testDev/index.ts Normal file
View File

@@ -0,0 +1,64 @@
import type { Plugin, ViteDevServer } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import { bold, cyan, green } from "colorette";
export default function myDevtool(): Plugin {
let server: ViteDevServer
return {
name: 'vite-plugin-hono_di',
apply: 'serve',
configureServer(_server) {
server = _server
const baseUrl = '__hono_di'
// API cho UI
// server.middlewares.use(`/${baseUrl}/api`, (req, res) => {
// res.setHeader('Content-Type', 'application/json')
// res.end(JSON.stringify({
// time: Date.now(),
// message: 'Hello from devtool'
// }))
// })
server.middlewares.use(`/${baseUrl}/api/tree`, async (_req, res) => {
try {
if (!cached) cached = await getTree(server);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(cached));
} catch (e: any) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e?.message ?? e) }));
}
});
server.middlewares.use(`/${baseUrl}/api/tree`, async (_req, res) => {
try {
if (!cached) cached = await getTree(server);
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(cached));
} catch (e: any) {
res.statusCode = 500;
res.end(JSON.stringify({ error: String(e?.message ?? e) }));
}
});
// Serve UI
server.middlewares.use(`/${baseUrl}`, (req, res) => {
const html = fs.readFileSync(
path.resolve(__dirname, 'ui/index.html'),
'utf-8'
)
res.setHeader('Content-Type', 'text/html')
res.end(html)
})
const _printUrls = server.printUrls;
const colorUrl = (url) => cyan(url.replace(/:(\d+)\//, (_, port) => `:${bold(port)}/`));
server.printUrls = () => {
_printUrls();
for (const localUrl of server.resolvedUrls?.local ?? []) {
const appUrl = localUrl.endsWith("/") ? localUrl : `${localUrl}/`;
const inspectorUrl = `${server.config.base && appUrl.endsWith(server.config.base) ? appUrl.slice(0, -server.config.base.length) : appUrl.slice(0, -1)}/${baseUrl}/`;
console.log(` ${green("➜")} ${bold("Hono-Di devTool")}: ${colorUrl(`${inspectorUrl}`)}`);
}
};
}
}
}

592
plugins/testDev/test.ts Normal file
View File

@@ -0,0 +1,592 @@
import type { Plugin, ViteDevServer } from "vite"
import { generate, GenerateInput, GenerateResult, GenerateType } from '@hono-di/generate';
import fs from "node:fs/promises"
import path from "node:path"
/* ------------------------ User Provided Types ------------------------ */
/* ------------------------ Utils ------------------------ */
const toPosix = (p: string) => p.split(path.sep).join("/")
const isIgnored = (p: string) =>
p.startsWith("node_modules/") ||
p.startsWith(".git/") ||
p.startsWith("dist/") ||
p.startsWith(".vite/") ||
p.includes("/.DS_Store") ||
p.includes("/.idea/") ||
p.includes("/.vscode/")
function resolveSafe(root: string, rel: string) {
const abs = path.resolve(root, rel)
const relCheck = path.relative(root, abs)
// Fix: Check if path goes outside root (starts with ..) or is absolute (different drive on win)
// This prevents partial matching vulnerabilities (e.g., /root vs /root_sibling)
if (relCheck.startsWith('..') || path.isAbsolute(relCheck)) {
throw new Error("Invalid path: Access denied")
}
return abs
}
function debounce<T extends (...args: any[]) => any>(fn: T, ms: number) {
let timer: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timer)
timer = setTimeout(() => fn(...args), ms)
}
}
/* ------------------------ Generator Logic (Server Side) ------------------------ */
// Helper to convert "my-user" to "MyUser"
const toPascalCase = (str: string) => str.replace(/(^\w|-\w)/g, (c) => c.replace('-', '').toUpperCase());
// Helper to convert "MyUser" to "my-user"
const toKebabCase = (str: string) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
// Map aliases to full types for internal logic
const ALIAS_MAP: Record<string, GenerateType> = {
mo: 'module', co: 'controller', s: 'service', pr: 'provider',
cl: 'class', itf: 'interface', pi: 'pipe', gu: 'guard',
f: 'filter', itc: 'interceptor', d: 'decorator'
};
type GenerateInputBody = Omit<GenerateInput, 'type'> & { type: GenerateType[] };
/* ------------------------ Tree Builder ------------------------ */
interface TreeNode {
name: string;
path: string;
type: "file" | "dir";
children?: TreeNode[];
}
async function buildTree(root: string, dir: string): Promise<TreeNode[]> {
let entries
try { entries = await fs.readdir(dir, { withFileTypes: true }) } catch (e) { return [] }
const out: TreeNode[] = []
for (const e of entries) {
const abs = path.join(dir, e.name)
const rel = toPosix(path.relative(root, abs))
if (!rel || isIgnored(rel + (e.isDirectory() ? "/" : ""))) continue
if (e.isDirectory()) {
out.push({ name: e.name, path: rel, type: "dir", children: await buildTree(root, abs) })
} else {
out.push({ name: e.name, path: rel, type: "file" })
}
}
out.sort((a, b) => a.type !== b.type ? (a.type === "dir" ? -1 : 1) : a.name.localeCompare(b.name))
return out
}
async function getTree(server: ViteDevServer) {
const root = server.config.root
return {
rootAbs: root,
tree: { name: path.basename(root), path: "", type: "dir", children: await buildTree(root, root) } as TreeNode,
}
}
/* ------------------------ Plugin ------------------------ */
export default function fileTreeVisualizer(): Plugin {
let serverRef: ViteDevServer
let cached: any
const rebuild = debounce(async () => {
if (!serverRef) return
try {
cached = await getTree(serverRef)
serverRef.ws.send({ type: "custom", event: "filetree:update", data: cached })
} catch (e) { serverRef.config.logger.error(`[filetree] Error: ${e}`) }
}, 100)
return {
name: "vite-plugin-filetree-visualizer",
apply: "serve",
configureServer(server) {
serverRef = server
server.httpServer?.once("listening", () => {
const base = server.resolvedUrls?.local?.[0] ?? "http://localhost:5173"
setTimeout(() => server.config.logger.info(` ➜ File Tree: \x1b[36m${base}__filetree/\x1b[0m\n`), 100)
})
/* ---- Middleware ---- */
server.middlewares.use("/__filetree/", async (req, res, next) => {
if (req.originalUrl !== '/__filetree/' && !req.originalUrl?.startsWith('/__filetree/api')) return next();
if (req.originalUrl?.startsWith('/__filetree/api')) return next();
try {
const html = await server.transformIndexHtml(req.url ?? '/', UI_HTML)
res.setHeader("Content-Type", "text/html; charset=utf-8")
res.end(html)
} catch (e) { next(e) }
})
const parseBody = (req: any): Promise<any> => {
return new Promise((resolve, reject) => {
let body = ""
req.on("data", (c: any) => (body += c))
req.on("end", () => { try { resolve(JSON.parse(body)) } catch (e) { reject(e) } })
req.on("error", reject)
})
}
/* ---- API Handlers ---- */
server.middlewares.use("/__filetree/api/tree", async (_, res) => {
cached ??= await getTree(server)
res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(cached))
})
// Generate API
server.middlewares.use("/__filetree/api/generate", async (req, res) => {
try {
const input: GenerateInputBody = await parseBody(req);
server.config.logger.info(`[filetree] GENERATE ${JSON.stringify(input.type)} ${input.name}`, { timestamp: true });
const result = await Promise.all(input.type.map(async (t) => {
return new Promise<GenerateResult['operations']>(async (resolve, reject) => {
const tmpRes = generate({...input, type: t})
if (!tmpRes.success) {
return reject(tmpRes.errors?.join(", ") || "Generation failed");
}
resolve(tmpRes.operations);
});
})).then((ops) => ops.flat()).then(ops => ({ success: true, operations: ops }));
// input.type.forEach((t, i) => {
// results.operations.push(...generate({...input, type: t}).operations);
// })
// 1. Calculate Operations
// 2. Execute Operations (if not dryRun)
if (result.success && !input.dryRun) {
for (const op of result.operations) {
const absPath = resolveSafe(server.config.root, op.path);
if (op.action === 'create' || op.action === 'overwrite') {
server.config.logger.info(` - Creating: ${op.path}`, { timestamp: true });
await fs.mkdir(path.dirname(absPath), { recursive: true });
await fs.writeFile(absPath, op.content || '');
}
}
}
res.end(JSON.stringify(result));
} catch (e: any) {
server.config.logger.error(`[filetree] Generate Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
// Standard File Ops
server.middlewares.use("/__filetree/api/file/create", async (req, res) => {
try {
const { path: rel, content = "" } = await parseBody(req)
server.config.logger.info(`[filetree] CREATE FILE ${rel}`, { timestamp: true });
const abs = resolveSafe(server.config.root, rel)
await fs.mkdir(path.dirname(abs), { recursive: true })
await fs.writeFile(abs, content)
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Create File Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/dir/create", async (req, res) => {
try {
const { path: rel } = await parseBody(req);
server.config.logger.info(`[filetree] CREATE DIR ${rel}`, { timestamp: true });
await fs.mkdir(resolveSafe(server.config.root, rel), { recursive: true });
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Create Dir Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/delete", async (req, res) => {
try {
const { path: rel } = await parseBody(req);
server.config.logger.info(`[filetree] DELETE ${rel}`, { timestamp: true });
await fs.rm(resolveSafe(server.config.root, rel), { recursive: true, force: true });
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Delete Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.middlewares.use("/__filetree/api/move", async (req, res) => {
try {
const { from, to } = await parseBody(req);
server.config.logger.info(`[filetree] MOVE ${from} -> ${to}`, { timestamp: true });
const a = resolveSafe(server.config.root, from); const b = resolveSafe(server.config.root, to);
await fs.mkdir(path.dirname(b), { recursive: true }); await fs.rename(a, b);
res.end(JSON.stringify({ ok: true }))
} catch (e: any) {
server.config.logger.error(`[filetree] Move Error: ${e.message}`, { timestamp: true });
res.statusCode = 500; res.end(JSON.stringify({ error: e.message }))
}
})
server.watcher.on("all", (event, file) => { if(!isIgnored(path.relative(server.config.root, file))) rebuild() })
},
}
}
/* ------------------------ UI ------------------------ */
const UI_HTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Project Explorer</title>
<!-- SweetAlert2 Dark Theme -->
<link href="https://cdn.jsdelivr.net/npm/@sweetalert2/theme-dark@4/dark.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11/dist/sweetalert2.min.js"></script>
<style>
:root { --bg: #1e1e1e; --sidebar: #252526; --text: #cccccc; --text-hover: #ffffff; --accent: #007fd4; --active: #37373d; --border: #333; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif; margin: 0; background: var(--bg); color: var(--text); font-size: 13px; overflow: hidden; height: 100vh; display: flex; flex-direction: column; }
/* Icons */
svg { width: 16px; height: 16px; fill: currentColor; }
.icon-folder { color: #dcb67a; }
.icon-file { color: #519aba; }
.icon-chevron { width: 14px; height: 14px; transition: transform 0.15s; color: #888; margin-right: 2px; }
.rotate-90 { transform: rotate(90deg); }
/* Header */
header { background: var(--sidebar); padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--border); height: 40px; box-sizing: border-box; }
.title { font-weight: 600; color: #fff; font-size: 14px; }
.actions { display: flex; gap: 8px; }
.btn-gen { background: #4caf50; border: none; color: white; padding: 4px 12px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 500; display: flex; align-items: center; gap: 4px; }
.btn-gen:hover { background: #45a049; }
.search-box { background: #3c3c3c; border: 1px solid transparent; color: white; border-radius: 4px; padding: 4px 8px; width: 200px; outline: none; font-size: 12px; }
.search-box:focus { border-color: var(--accent); }
/* Main Tree */
#app { flex: 1; overflow-y: auto; padding: 10px 0; }
ul { list-style: none; padding-left: 0; margin: 0; }
li { user-select: none; }
.row { display: flex; align-items: center; padding: 4px 16px; cursor: pointer; border-left: 2px solid transparent; white-space: nowrap; height: 22px; }
.row:hover { background: var(--active); color: var(--text-hover); }
.row.selected { background: #094771; color: #fff; border-left-color: var(--accent); }
.node-name { margin-left: 6px; }
/* Context Menu */
#context-menu { position: fixed; background: #252526; border: 1px solid #454545; box-shadow: 0 4px 12px rgba(0,0,0,0.5); border-radius: 4px; padding: 4px 0; display: none; z-index: 100; min-width: 160px; }
.menu-item { padding: 6px 16px; cursor: pointer; color: #ccc; display: flex; align-items: center; gap: 8px; }
.menu-item:hover { background: #094771; color: white; }
.separator { height: 1px; background: #454545; margin: 4px 0; }
/* Utility classes */
.hidden { display: none !important; }
/* Custom Swals */
.swal2-popup { font-size: 13px !important; border: 1px solid #454545 !important; }
.swal2-input, .swal2-select { margin: 8px auto !important; font-size: 14px !important; }
.gen-form { display: flex; flex-direction: column; gap: 10px; text-align: left; }
.gen-form label { font-weight: 600; color: #ccc; font-size: 12px; margin-bottom: 2px; }
.gen-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: #ccc; }
/* Type Grid */
.type-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; border: 1px solid #3c3c3c; padding: 8px; border-radius: 4px; max-height: 150px; overflow-y: auto; background: #252526; }
.type-item { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; user-select: none; }
.type-item input { margin: 0; cursor: pointer; }
</style>
</head>
<body>
<header>
<div class="title">EXPLORER</div>
<div class="actions">
<button class="btn-gen" onclick="openGenerator()">⚡ Generate</button>
<input id="q" class="search-box" placeholder="Search..." autocomplete="off" />
</div>
</header>
<div id="app"></div>
<!-- Context Menu -->
<div id="context-menu">
<div class="menu-item" onclick="promptCreate('file')">📄 New File</div>
<div class="menu-item" onclick="promptCreate('dir')">📁 New Folder</div>
<div class="menu-item" onclick="openGenerator()">⚡ Generate...</div>
<div class="separator"></div>
<div class="menu-item" onclick="promptRename()">✏️ Rename</div>
<div class="menu-item" onclick="promptDelete()" style="color: #ff6b6b">🗑️ Delete</div>
</div>
<script type="module">
/* --- State --- */
let fullTree = null;
let expandedPaths = new Set(['']);
let selectedPath = null;
let contextNode = null;
const app = document.getElementById('app');
const qEl = document.getElementById('q');
const ctxMenu = document.getElementById('context-menu');
/* --- Icons --- */
const ICONS = {
chevron: '<svg viewBox="0 0 16 16"><path d="M6 4l4 4-4 4z"/></svg>',
folder: '<svg viewBox="0 0 16 16"><path d="M14.5 3H7.71l-.85-.85L6.51 2h-5C.68 2 0 2.68 0 3.5v9c0 .82.68 1.5 1.5 1.5h13c.82 0 1.5-.68 1.5-1.5v-8c0-.82-.68-1.5-1.5-1.5z"/></svg>',
file: '<svg viewBox="0 0 16 16"><path d="M13 6h-3V3H4v10h10V6zM3 2h8l3 3v9a1 1 0 0 1-1 1H3a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z"/></svg>'
};
/* --- API --- */
async function fetchTree(){
try {
const r = await fetch('/__filetree/api/tree');
fullTree = (await r.json()).tree;
draw();
} catch(e) { console.error(e); }
}
async function callApi(endpoint, body) {
const r = await fetch('/__filetree/api/' + endpoint, { method: 'POST', body: JSON.stringify(body) });
const res = await r.json();
if(!r.ok) throw new Error(res.error || 'Unknown error');
return res;
}
/* --- Render --- */
function filterTree(node, query) {
if (!query) return node;
const matchesSelf = node.name.toLowerCase().includes(query.toLowerCase());
if (node.type === 'file') return matchesSelf ? node : null;
const filteredChildren = (node.children || []).map(c => filterTree(c, query)).filter(Boolean);
if (matchesSelf || filteredChildren.length > 0) {
if(query) expandedPaths.add(node.path);
return { ...node, children: filteredChildren };
}
return null;
}
function renderNode(node, depth = 0) {
const isDir = node.type === 'dir';
const isExpanded = expandedPaths.has(node.path);
const isSelected = selectedPath === node.path;
const li = document.createElement('li');
const row = document.createElement('div');
row.className = \`row \${isSelected ? 'selected' : ''}\`;
row.style.paddingLeft = (depth * 12) + 'px';
row.onclick = () => {
selectedPath = node.path;
if(isDir) { isExpanded ? expandedPaths.delete(node.path) : expandedPaths.add(node.path); }
draw();
};
row.oncontextmenu = (e) => {
e.preventDefault(); e.stopPropagation();
selectedPath = node.path; contextNode = node;
showContextMenu(e.clientX, e.clientY); draw();
};
let icon = isDir ?
\`<div class="icon-chevron \${isExpanded ? 'rotate-90' : ''}">\${ICONS.chevron}</div><div class="icon-folder">\${ICONS.folder}</div>\` :
\`<div style="width:16px"></div><div class="icon-file">\${ICONS.file}</div>\`;
row.innerHTML = \`\${icon}<span class="node-name">\${node.name}</span>\`;
li.appendChild(row);
if (isDir && isExpanded && node.children) {
const ul = document.createElement('ul');
node.children.forEach(c => ul.appendChild(renderNode(c, depth + 1)));
li.appendChild(ul);
}
return li;
}
function draw() {
app.innerHTML = '';
if (!fullTree) return;
const treeToRender = filterTree(fullTree, qEl.value.trim());
if (treeToRender) { const ul = document.createElement('ul'); ul.appendChild(renderNode(treeToRender)); app.appendChild(ul); }
}
/* --- Context Menu --- */
function showContextMenu(x, y) {
ctxMenu.style.display = 'block';
const w = window.innerWidth, h = window.innerHeight;
ctxMenu.style.left = (x + ctxMenu.offsetWidth > w ? w - ctxMenu.offsetWidth : x) + 'px';
ctxMenu.style.top = (y + ctxMenu.offsetHeight > h ? h - ctxMenu.offsetHeight : y) + 'px';
}
document.addEventListener('click', () => ctxMenu.style.display = 'none');
/* --- Actions (SweetAlert2) --- */
window.promptCreate = async (type) => {
const isDir = type === 'dir';
const node = contextNode || fullTree;
const base = node.type === 'dir' ? node.path : node.path.split('/').slice(0, -1).join('/');
const { value: name } = await Swal.fire({
title: isDir ? 'New Folder' : 'New File',
input: 'text',
inputLabel: \`Inside: /\${base}\`,
inputPlaceholder: isDir ? 'folder_name' : 'file.ts',
showCancelButton: true
});
if (name) {
const fullPath = base ? \`\${base}/\${name}\` : name;
try {
await callApi(isDir ? 'dir/create' : 'file/create', { path: fullPath });
const toast = Swal.mixin({ toast: true, position: 'bottom-end', showConfirmButton: false, timer: 3000 });
toast.fire({ icon: 'success', title: 'Created successfully' });
} catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
window.promptRename = async () => {
if (!contextNode || !contextNode.path) return;
const { value: newName } = await Swal.fire({
title: 'Rename',
input: 'text',
inputValue: contextNode.name,
showCancelButton: true
});
if (newName && newName !== contextNode.name) {
const base = contextNode.path.split('/').slice(0, -1).join('/');
const to = base ? \`\${base}/\${newName}\` : newName;
try { await callApi('move', { from: contextNode.path, to }); }
catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
window.promptDelete = async () => {
if (!contextNode || !contextNode.path) return;
const result = await Swal.fire({
title: 'Are you sure?',
text: \`Delete \${contextNode.name}?\`,
icon: 'warning',
showCancelButton: true,
confirmButtonColor: '#d33',
confirmButtonText: 'Yes, delete it!'
});
if (result.isConfirmed) {
try { await callApi('delete', { path: contextNode.path }); }
catch(e) { Swal.fire('Error', e.message, 'error'); }
}
};
/* --- Generator Wizard --- */
window.openGenerator = async () => {
const node = contextNode || fullTree;
let initialPath = node ? (node.type === 'dir' ? node.path : node.path.split('/').slice(0, -1).join('/')) : '';
if (!initialPath) initialPath = 'src';
const types = [
'module', 'controller', 'service', 'provider',
'class', 'interface', 'pipe', 'guard',
'filter', 'interceptor', 'decorator'
];
const typeChecks = types.map(t => \`
<label class="type-item">
<input type="checkbox" class="type-cb" value="\${t}" \${t==='controller' || t==='service'?'checked':''}> \${t.charAt(0).toUpperCase() + t.slice(1)}
</label>
\`).join('');
const { value: formValues } = await Swal.fire({
title: 'Generate Resource',
html: \`
<div class="gen-form">
<div>
<label>Types</label>
<div class="type-grid">
\${typeChecks}
</div>
</div>
<div>
<label>Name</label>
<input id="swal-name" class="swal2-input" placeholder="e.g. user-auth" style="margin:4px 0; width:100%; box-sizing:border-box;">
</div>
<div>
<label>Path (Relative to root)</label>
<input id="swal-path" class="swal2-input" value="\${initialPath}" style="margin:4px 0; width:100%; box-sizing:border-box;">
</div>
<div class="gen-row" style="margin-top:10px">
<input type="checkbox" id="swal-flat">
<label for="swal-flat" style="margin:0;cursor:pointer">
Flat <span style="color:#888; font-weight:normal; font-size: 0.9em">(No sub-folder)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-spec" checked>
<label for="swal-spec" style="margin:0;cursor:pointer">
Spec <span style="color:#888; font-weight:normal; font-size: 0.9em">(Generate test file)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-skip-import">
<label for="swal-skip-import" style="margin:0;cursor:pointer">
Skip Import <span style="color:#888; font-weight:normal; font-size: 0.9em">(Do not import to module)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-force">
<label for="swal-force" style="margin:0;cursor:pointer">
Force <span style="color:#888; font-weight:normal; font-size: 0.9em">(Overwrite existing)</span>
</label>
</div>
<div class="gen-row">
<input type="checkbox" id="swal-dry">
<label for="swal-dry" style="margin:0;cursor:pointer;color:#ffab40">
Dry Run <span style="color:#ffcc80; font-weight:normal; font-size: 0.9em">(Simulate only)</span>
</label>
</div>
</div>
\`,
focusConfirm: false,
showCancelButton: true,
confirmButtonText: 'Generate',
preConfirm: () => {
const selectedTypes = Array.from(document.querySelectorAll('.type-cb:checked')).map(cb => cb.value);
return {
type: selectedTypes,
name: document.getElementById('swal-name').value,
path: document.getElementById('swal-path').value,
flat: document.getElementById('swal-flat').checked,
spec: document.getElementById('swal-spec').checked,
skipImport: document.getElementById('swal-skip-import').checked,
force: document.getElementById('swal-force').checked,
dryRun: document.getElementById('swal-dry').checked
}
}
});
if (formValues) {
if(!formValues.name) return Swal.fire('Error', 'Name is required', 'error');
if(formValues.type.length === 0) return Swal.fire('Error', 'Select at least one type', 'error');
try {
const res = await callApi('generate', formValues);
const opsHtml = res.operations.map(op => {
const color = op.action === 'create' ? '#4caf50' : '#ff9800';
return \`<div style="text-align:left; font-family:monospace; margin-top:4px">
<span style="color:\${color}; font-weight:bold">\${op.action.toUpperCase()}</span>
\${op.path.split('/').pop()}
</div>\`;
}).join('');
Swal.fire({
title: res.success ? 'Success' : 'Partial Success',
html: \`<div style="font-size:12px">\${opsHtml}</div>\`,
icon: res.success ? 'success' : 'warning'
});
} catch (e) {
Swal.fire('Error', e.message, 'error');
}
}
}
/* --- HMR --- */
if (import.meta.hot) {
import.meta.hot.on('filetree:update', d => { fullTree = d.tree; draw(); });
}
qEl.oninput = () => draw();
fetchTree();
</script>
</body>
</html>`

View File

@@ -0,0 +1,148 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>File Tree Visualizer</title>
<style>
body { font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto; margin: 0; }
header { position: sticky; top: 0; background: #111; color: #fff; padding: 12px 14px; display:flex; gap:10px; align-items:center; }
header input { flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid #333; background: #1b1b1b; color:#fff; }
header button { padding: 8px 10px; border-radius: 8px; border: 1px solid #333; background: #1b1b1b; color:#fff; cursor:pointer; }
main { padding: 10px 14px; }
.muted { color: #666; font-size: 12px; }
ul { list-style: none; padding-left: 16px; margin: 6px 0; }
li { margin: 2px 0; }
.row { display:flex; gap:8px; align-items:center; }
.twisty { width: 16px; text-align:center; cursor:pointer; user-select:none; }
.name { cursor: default; }
.file { color: #222; }
.dir { font-weight: 600; }
.path { color: #888; font-size: 12px; }
.hidden { display:none; }
.pill { font-size: 12px; padding: 2px 8px; border: 1px solid #333; border-radius: 999px; background:#1b1b1b; color:#ddd; }
</style>
</head>
<body>
<header>
<span class="pill">/__hono_di/</span>
<input id="q" placeholder="Filter (e.g. src/components or .ts)" />
<button id="refresh">Refresh</button>
</header>
<main>
<div class="muted" id="status">Loading…</div>
<div id="app"></div>
</main>
<script type="module">
const app = document.getElementById('app');
const statusEl = document.getElementById('status');
const qEl = document.getElementById('q');
const refreshBtn = document.getElementById('refresh');
let tree = null;
let expanded = new Set([""]); // expand root by default
function matches(node, q) {
if (!q) return true;
const hay = (node.path + "/" + node.name).toLowerCase();
return hay.includes(q);
}
function renderNode(node, q) {
const li = document.createElement('li');
const row = document.createElement('div');
row.className = 'row';
const twisty = document.createElement('div');
twisty.className = 'twisty';
const name = document.createElement('div');
name.className = 'name ' + (node.type === 'dir' ? 'dir' : 'file');
name.textContent = node.type === 'dir' ? node.name + '/' : node.name;
const pathEl = document.createElement('div');
pathEl.className = 'path';
pathEl.textContent = node.path;
row.appendChild(twisty);
row.appendChild(name);
row.appendChild(pathEl);
li.appendChild(row);
if (node.type === 'dir') {
const isOpen = expanded.has(node.path);
twisty.textContent = isOpen ? '▾' : '▸';
twisty.onclick = () => {
if (expanded.has(node.path)) expanded.delete(node.path);
else expanded.add(node.path);
draw();
};
// children
const ul = document.createElement('ul');
ul.className = isOpen ? '' : 'hidden';
const kids = node.children || [];
for (const child of kids) {
// prune theo filter: dir được giữ nếu nó hoặc con nó match
if (q) {
if (child.type === 'file') {
if (!matches(child, q)) continue;
} else {
// dir: giữ nếu match hoặc có con match
const hasMatch = matches(child, q) || (child.children || []).some(c => matches(c, q));
if (!hasMatch) continue;
expanded.add(child.path); // auto expand khi filter
}
}
ul.appendChild(renderNode(child, q));
}
li.appendChild(ul);
} else {
twisty.textContent = '·';
}
return li;
}
function draw() {
if (!tree) return;
const q = (qEl.value || '').trim().toLowerCase();
app.innerHTML = '';
const ul = document.createElement('ul');
ul.appendChild(renderNode(tree, q));
app.appendChild(ul);
statusEl.textContent = 'Updated: ' + new Date().toLocaleTimeString();
}
async function fetchTree() {
statusEl.textContent = 'Fetching…';
const res = await fetch('/__hono_di/api/tree');
tree = await res.json();
draw();
}
refreshBtn.onclick = fetchTree;
qEl.oninput = () => draw();
// Realtime via Vite HMR websocket (custom event)
try {
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}`, 'vite-hmr');
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
if (msg?.type === 'custom' && msg?.event === 'filetree:update') {
tree = msg.data;
draw();
}
} catch {}
};
} catch {}
fetchTree();
</script>
</body>
</html>

View File

@@ -1,5 +1,5 @@
import type { Connect, Plugin } from "vite";
import { name as packageName } from "./package.json";
import { name as packageName } from "../package.json";
import { createMiddleware } from "@hattip/adapter-node";
import { pathToFileURL } from "url";
@@ -51,14 +51,13 @@ export function vitePluginSsrMiddleware({
});
loadModule = (id: string) => runner.import(id);
}
// const mod = await loadModule(entry);
const handler: Connect.NextHandleFunction = async (req, res, next) => {
// expose ViteDevServer via request
Object.defineProperty(req, "viteDevServer", { value: server });
try {
const mod = await loadModule(entry);
// console.log("preview module loaded:", mod);
await createMiddleware((ctx) => mod["default"].fetch(ctx.request))(req, res, next);
// await mod["default"](req, res, next);
} catch (e) {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

@@ -6,37 +6,20 @@ const GET_PAYLOAD_PARAM = "payload";
export function httpClientAdapter(opts: {
url: string;
pathsForGET?: string[];
JSON?: Partial<JsonTransformer>;
headers?: () => Promise<Record<string, string>> | Record<string, string>;
}): TinyRpcClientAdapter {
const JSON: JsonTransformer = {
parse: globalThis.JSON.parse,
stringify: globalThis.JSON.stringify as JsonTransformer["stringify"],
...opts.JSON,
};
return {
send: async (data) => {
const url = [opts.url, data.path].join("/");
const extraHeaders = opts.headers ? await opts.headers() : {};
const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const payload = JSON.stringify(data.args);
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
let req: Request;
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
);
} else {
req = new Request(url, {
@@ -44,13 +27,11 @@ 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}`);
@@ -64,10 +45,8 @@ export function httpClientAdapter(opts: {
);
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
await res.text()
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);

View File

@@ -3,28 +3,15 @@ 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 extraHeaders = opts.headers ? await opts.headers() : {};
const payload = JSON.stringify(data.args, (headerObj) => {
if (headerObj) {
Object.assign(extraHeaders, headerObj);
}
});
const payload = JSON.stringify(data.args);
const method = opts.pathsForGET?.includes(data.path)
? "GET"
: "POST";
@@ -32,11 +19,8 @@ export function httpClientAdapter(opts: {
if (method === "GET") {
req = new Request(
url +
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload }),
{
headers: extraHeaders
}
"?" +
new URLSearchParams({ [GET_PAYLOAD_PARAM]: payload })
);
} else {
req = new Request(url, {
@@ -44,7 +28,6 @@ export function httpClientAdapter(opts: {
body: payload,
headers: {
"content-type": "application/json; charset=utf-8",
...extraHeaders,
},
credentials: "include",
});
@@ -62,7 +45,6 @@ export function httpClientAdapter(opts: {
} else {
res = await fetch(req);
}
if (!res.ok) {
// throw new Error(`HTTP error: ${res.status}`);
throw new Error(
@@ -76,8 +58,7 @@ export function httpClientAdapter(opts: {
// throw TinyRpcError.deserialize(res.status);
}
const result: Result<unknown, unknown> = JSON.parse(
await res.text(),
() => Object.fromEntries((res.headers as any).entries() ?? [])
await res.text()
);
if (!result.ok) {
throw TinyRpcError.deserialize(result.value);

242
src/client/api/rpc/auth.ts Normal file
View File

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

View File

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

335
src/client/api/rpc/index.ts Normal file
View File

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

View File

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

View File

@@ -0,0 +1,19 @@
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;
const endpoint = "/rpc";
const url = import.meta.env.SSR ? "http://localhost" : "";
const headers: Record<string, string> = {}; // inject headers to demonstrate context
export const client = proxyTinyRpc<RpcRoutes>({
adapter: httpClientAdapter({
url: url + endpoint,
pathsForGET: [],
}),
});

View File

@@ -0,0 +1,51 @@
<script lang="ts" setup>
import Home from "@/client/components/icons/Home.vue";
import Video from "@/client/components/icons/Video.vue";
import Credit from "@/client/components/icons/Credit.vue";
import Upload from "./icons/Upload.vue";
import { cn } from "@/client/lib/utils";
import { useAuthStore } from "@/client/stores/auth";
import { createStaticVNode } from "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=":m: w-12 h-12 rounded-2xl hover:bg-primary/15 flex">
<button class=":m: h-[38px] w-[38px] rounded-full m-a ring-2 ring flex press-animated" @click="auth.logout()">
<img class=":m: 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=":m: 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"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition-all duration-200 ease-in-out"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform -translate-y-4" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
</div>
</main>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<router-view/>
</template>

View File

@@ -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="currentColor" />
fill="#1e3050" />
</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="currentColor" />
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
defineProps<{ class?: string, filled?: boolean }>();
</script>

View File

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

View File

@@ -0,0 +1,3 @@
<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>
</template>

View File

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

View File

@@ -1,17 +1,18 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 539 535">
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="v-mid m-a" height="24" width="24"
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="var(--fill1)" />
fill="#a6acb9" />
<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="currentColor" />
fill="#1e3050" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="-11 -259 535 533">
<svg xmlns="http://www.w3.org/2000/svg" v-else class="v-mid m-a" height="24" width="24" 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="currentColor" />
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>
defineProps<{ class?: string, filled?: boolean }>();
</script>

View File

@@ -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="currentColor" />
fill="#1e3050" />
</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="currentColor" />
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
defineProps<{ class?: string, filled?: boolean }>();
</script>

View File

@@ -1,18 +1,18 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" class="min-w-[28px]" viewBox="0 0 596 468">
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="28" 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="var(--fill1)" />
fill="#a6acb9" />
<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="currentColor" />
fill="#1e3050" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else class="min-w-[28px]" viewBox="-10 -226 596 468">
<svg xmlns="http://www.w3.org/2000/svg" v-else width="28" 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="currentColor" />
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
defineProps<{ class?: string, filled?: boolean }>();
</script>

View File

@@ -1,16 +1,16 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404">
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" width="24" 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="var(--fill1)" />
fill="#a6acb9" />
<path d="M394 135v134l90 72c4 3 9 5 14 5 13 0 24-11 24-24V82c0-13-11-24-24-24-5 0-10 2-14 5l-90 72z"
fill="currentColor" />
fill="#1e3050" />
</svg>
<svg xmlns="http://www.w3.org/2000/svg" v-else viewBox="22 -194 564 404">
<svg xmlns="http://www.w3.org/2000/svg" v-else width="24" 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="currentColor" />
fill="#1e3050" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>
defineProps<{ class?: string, filled?: boolean }>();
</script>

View File

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

12
src/client/index.ts Normal file
View File

@@ -0,0 +1,12 @@
import createVueApp from '@/shared/createVueApp';
import 'uno.css';
async function render() {
const { app, router } = createVueApp();
router.isReady().then(() => {
app.mount('body', true)
})
}
render().catch((error) => {
console.error('Error during app initialization:', error)
})

View File

@@ -0,0 +1,46 @@
import { initializeApp } from "firebase/app";
import { createUserWithEmailAndPassword, getAuth, GoogleAuthProvider, sendPasswordResetEmail, signInWithEmailAndPassword, signInWithPopup } from "firebase/auth";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
const firebaseConfig = {
apiKey: "AIzaSyBTr0L5qxdrVEtWuP2oAicJXQvVyeXkMts",
authDomain: "trello-7ea39.firebaseapp.com",
projectId: "trello-7ea39",
storageBucket: "trello-7ea39.firebasestorage.app",
messagingSenderId: "321067890572",
appId: "1:321067890572:web:e34e1e657125d37be688a9"
};
// Initialize Firebase
const appFirebase = initializeApp(firebaseConfig);
const provider = new GoogleAuthProvider();
const auth = getAuth(appFirebase);
export const googleAuth = signInWithPopup(auth, provider).then((result) => {
console.log('User signed in:', result.user);
})
export const emailAuth = (username: string, password: string) => {
return signInWithEmailAndPassword(auth, username, password)
}
export const forgotPassword = (email: string) => {
return sendPasswordResetEmail(auth, email)
.then(() => {
console.log('Password reset email sent');
})
.catch((error) => {
console.error('Error sending password reset email:', error);
throw error;
});
}
export const signUp = (email: string, password: string) => {
return createUserWithEmailAndPassword(auth, email, password)
.then((userCredential) => {
console.log('User signed up:', userCredential.user);
return userCredential.user;
})
.catch((error) => {
console.error('Error signing up:', error);
throw error;
});
}

View File

@@ -84,7 +84,7 @@ export function buildBootstrapScript() {
assets: [],
},
"1": {
file: "src/client.ts",
file: "src/client/index.ts",
isEntry: true,
css: [],
},

File diff suppressed because one or more lines are too long

View File

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

72
src/client/lib/swr/cache/index.ts vendored Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

50
src/client/lib/utils.ts Normal file
View File

@@ -0,0 +1,50 @@
import type { ClassValue } from "clsx";
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export function debounce<Func extends (...args: any[]) => any>(func: Func, wait: number): Func {
let timeout: ReturnType<typeof setTimeout> | null;
return function(this: any, ...args: any[]) {
if (timeout) clearTimeout(timeout);
timeout = setTimeout(() => {
func.apply(this, args);
}, wait);
} as Func;
}
type AspectInfo = {
width: number;
height: number;
ratio: string; // ví dụ: "16:9"
float: number; // ví dụ: 1.777...
};
function gcd(a: number, b: number): number {
return b === 0 ? a : gcd(b, a % b);
}
export function getImageAspectRatio(url: string): Promise<AspectInfo> {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const w = img.naturalWidth;
const h = img.naturalHeight;
const g = gcd(w, h);
resolve({
width: w,
height: h,
ratio: `${w / g}:${h / g}`,
float: w / h
});
};
img.onerror = () => reject(new Error("Cannot load image"));
img.src = url;
});
}

View File

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

View File

@@ -0,0 +1,3 @@
<template>
<div>Add video</div>
</template>

View File

@@ -0,0 +1,56 @@
<template>
<div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="text-sm text-gray-600 mb-2">
Enter your email address and we'll send you a link to reset your password.
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText name="email" type="email" placeholder="you@example.com" fluid />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
</div>
<Button type="submit" label="Send Reset Link" fluid />
<div class="text-center mt-2">
<router-link to="/login" replace
class="inline-flex items-center text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors">
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 19l-7-7m0 0l7-7m-7 7h18"></path>
</svg>
Back to Sign in
</router-link>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod';
const initialValues = reactive({
email: ''
});
const resolver = zodResolver(
z.object({
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' })
})
);
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
console.log('Form submitted:', values);
// toast.add({ severity: 'success', summary: 'Success', detail: 'Reset link sent', life: 3000 });
// Handle actual forgot password logic here
}
};
</script>

View File

@@ -0,0 +1,44 @@
<template>
<div class=":m: w-full max-w-md bg-white p-8 rounded-xl border border-gray-200 m-auto overflow-hidden">
<div class="text-center mb-8">
<router-link to="/" class=":m: inline-flex items-center justify-center w-12 h-12 mb-4">
<img class="w-12 h-12" src="/apple-touch-icon.png" alt="Logo" />
</router-link>
<h2 class="text-2xl font-bold text-gray-900">
{{ content[route.name as keyof typeof content]?.title || '' }}
</h2>
<p class="text-gray-500 text-sm mt-1">
{{ content[route.name as keyof typeof content]?.subtitle || '' }}
</p>
<vue-head :input="{
title: content[route.name as keyof typeof content]?.headTitle || 'Authentication',
meta: [
{ name: 'description', content: content[route.name as keyof typeof content]?.subtitle || '' }
]
}" />
</div>
<router-view />
</div>
</template>
<script setup lang="ts">
import { useRoute } from 'vue-router';
const route = useRoute();
const content = {
login: {
headTitle: "Login to your account",
title: 'Welcome back',
subtitle: 'Please enter your details to sign in.'
},
signup: {
headTitle: "Create your account",
title: 'Create your account',
subtitle: 'Please fill in the information to create your account.'
},
forgot: {
title: 'Forgot your password?',
subtitle: "Enter your email address and we'll send you a link to reset your password.",
headTitle: "Reset your password"
}
}
</script>

View File

@@ -0,0 +1,105 @@
<template>
<div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<Message v-if="auth.error" severity="error">Failed to sign in. Please check your credentials or try again later.</Message>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email or Username</label>
<InputText name="email" type="text" placeholder="admin or user@example.com" fluid
:disabled="auth.loading" />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="••••••••" :feedback="false" toggleMask fluid
:inputStyle="{ width: '100%' }" :disabled="auth.loading" />
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error?.message }}</Message>
</div>
<div class=":m: flex items-center justify-between">
<div class=":m: flex items-center gap-2">
<Checkbox inputId="remember-me" name="rememberMe" binary :disabled="auth.loading" />
<label for="remember-me" class=":m: text-sm text-gray-900">Remember me</label>
</div>
<div class="text-sm">
<router-link to="/forgot"
class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Forgot
password?</router-link>
</div>
</div>
<Button type="submit" :label="auth.loading ? 'Signing in...' : 'Sign in'" fluid :loading="auth.loading" />
<div class="relative my-4">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-gray-300"></div>
</div>
<div class="relative flex justify-center text-sm">
<span class="px-2 bg-white text-gray-500">Or continue with</span>
</div>
</div>
<Button type="button" variant="outlined" severity="secondary"
class="w-full flex items-center justify-center gap-2" @click="loginWithGoogle" :disabled="auth.loading">
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M12.545,10.239v3.821h5.445c-0.712,2.315-2.647,3.972-5.445,3.972c-3.332,0-6.033-2.701-6.033-6.032s2.701-6.032,6.033-6.032c1.498,0,2.866,0.549,3.921,1.453l2.814-2.814C17.503,2.988,15.139,2,12.545,2C7.021,2,2.543,6.477,2.543,12s4.478,10,10.002,10c8.396,0,10.249-7.85,9.426-11.748L12.545,10.239z" />
</svg>
Google
</Button>
<p class="mt-4 text-center text-sm text-gray-600">
Don't have an account?
<router-link to="/sign-up" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign up
for free</router-link>
</p>
<!-- Hint for demo credentials -->
<div class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
<p class="text-xs text-blue-800 font-medium mb-1">Demo Credentials:</p>
<p class="text-xs text-blue-600">Username: <code class="bg-blue-100 px-1 rounded">admin</code> |
Password: <code class="bg-blue-100 px-1 rounded">admin123</code></p>
<p class="text-xs text-blue-600">Email: <code class="bg-blue-100 px-1 rounded">user@example.com</code> |
Password: <code class="bg-blue-100 px-1 rounded">password</code></p>
</div>
</Form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod';
import { useAuthStore } from '@/client/stores/auth';
const auth = useAuthStore();
// const $form = Form.useFormContext();
const initialValues = reactive({
email: '',
password: '',
rememberMe: false
});
watch(() => initialValues, (newValues) => {
auth.error = null;
// console.log('Form values changed:', newValues);
});
const resolver = zodResolver(
z.object({
email: z.string().min(1, { message: 'Email or username is required.' }),
password: z.string().min(1, { message: 'Password is required.' })
})
);
const onFormSubmit = async ({ valid, values }: FormSubmitEvent) => {
if (valid) auth.login(values.email, values.password);
};
const loginWithGoogle = () => {
console.log('Login with Google');
// Handle Google login logic here
};
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="w-full">
<Form v-slot="$form" :resolver="resolver" :initialValues="initialValues" @submit="onFormSubmit"
class="flex flex-col gap-4 w-full">
<div class="flex flex-col gap-1">
<label for="name" class="text-sm font-medium text-gray-700">Full Name</label>
<InputText name="name" placeholder="John Doe" fluid />
<Message v-if="$form.name?.invalid" severity="error" size="small" variant="simple">{{
$form.name.error?.message }}</Message>
</div>
<div class="flex flex-col gap-1">
<label for="email" class="text-sm font-medium text-gray-700">Email address</label>
<InputText name="email" type="email" placeholder="you@example.com" fluid />
<Message v-if="$form.email?.invalid" severity="error" size="small" variant="simple">{{
$form.email.error?.message }}</Message>
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-gray-700">Password</label>
<Password name="password" placeholder="Create a password" :feedback="true" toggleMask fluid
:inputStyle="{ width: '100%' }" />
<small class="text-gray-500">Must be at least 8 characters.</small>
<Message v-if="$form.password?.invalid" severity="error" size="small" variant="simple">{{
$form.password.error?.message }}</Message>
</div>
<Button type="submit" label="Create Account" fluid />
<p class="mt-4 text-center text-sm text-gray-600">
Already have an account?
<router-link to="/login" class="font-medium text-blue-600 hover:text-blue-500 hover:underline">Sign
in</router-link>
</p>
</Form>
</div>
</template>
<script setup lang="ts">
import { reactive } from 'vue';
import { Form, type FormSubmitEvent } from '@primevue/forms';
import { zodResolver } from '@primevue/forms/resolvers/zod';
import { z } from 'zod';
const initialValues = reactive({
name: '',
email: '',
password: ''
});
const resolver = zodResolver(
z.object({
name: z.string().min(1, { message: 'Name is required.' }),
email: z.string().min(1, { message: 'Email is required.' }).email({ message: 'Invalid email address.' }),
password: z.string().min(8, { message: 'Password must be at least 8 characters.' })
})
);
const onFormSubmit = ({ valid, values }: FormSubmitEvent) => {
if (valid) {
console.log('Form submitted:', values);
// toast.add({ severity: 'success', summary: 'Success', detail: 'Account created successfully', life: 3000 });
// Handle actual signup logic here
}
};
</script>

179
src/client/routes/index.ts Normal file
View File

@@ -0,0 +1,179 @@
import { type ReactiveHead, type ResolvableValue } from "@unhead/vue";
import { headSymbol } from '@unhead/vue'
import {
createMemoryHistory,
createRouter,
createWebHistory,
type RouteRecordRaw,
} from "vue-router";
import { useAuthStore } from "@/client/stores/auth";
type RouteData = RouteRecordRaw & {
meta?: ResolvableValue<ReactiveHead> & { requiresAuth?: boolean };
children?: RouteData[];
};
const routes: RouteData[] = [
{
path: "/",
component: () => import("@/client/components/RootLayout.vue"),
children: [
{
path: "",
component: () => import("./public-routes/Layout.vue"),
children: [
{
path: "",
component: () => import("./public-routes/Home.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
},
{
path: "/terms",
name: "terms",
component: () => import("./public-routes/Terms.vue"),
},
{
path: "/privacy",
name: "privacy",
component: () => import("./public-routes/Privacy.vue"),
},
]
},
{
path: "",
component: () => import("./auth/layout.vue"),
beforeEnter: (to, from, next) => {
const auth = useAuthStore();
if (auth.user) {
next({ name: "overview" });
} else {
next();
}
},
children: [
{
path: "login",
name: "login",
component: () => import("./auth/login.vue"),
},
{
path: "sign-up",
name: "signup",
component: () => import("./auth/signup.vue"),
},
{
path: "forgot",
name: "forgot",
component: () => import("./auth/forgot.vue"),
},
],
},
{
path: "",
component: () => import("@/client/components/DashboardLayout.vue"),
meta: { requiresAuth: true },
children: [
{
path: "",
name: "overview",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Overview - Holistream',
},
}
},
{
path: "upload",
name: "upload",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Upload - Holistream',
},
}
},
{
path: "video",
name: "video",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Videos - Holistream',
meta: [
{ name: 'description', content: 'Manage your video content.' },
],
},
}
},
{
path: "plans",
name: "plans",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Plans & Billing',
meta: [
{ name: 'description', content: 'Manage your plans and billing information.' },
],
},
}
},
{
path: "notification",
name: "notification",
component: () => import("./add/Add.vue"),
meta: {
head: {
title: 'Notification - Holistream',
},
}
},
],
},
{
path: "/:pathMatch(.*)*",
name: "not-found",
component: () => import("./NotFound.vue"),
}
],
},
];
const createAppRouter = () => {
const router = createRouter({
history: import.meta.env.SSR
? createMemoryHistory() // server
: createWebHistory(), // client
routes,
scrollBehavior(to, from, savedPosition) {
if (savedPosition) {
return savedPosition
}
return { top: 0 }
}
});
router.beforeEach((to, from, next) => {
const auth = useAuthStore();
const head = inject(headSymbol);
(head as any).push(to.meta.head || {});
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!auth.user) {
next({ name: "login" });
} else {
next();
}
} else {
next();
}
});
return router;
}
export default createAppRouter;

View File

@@ -0,0 +1,231 @@
<template>
<section class=":m: relative pt-32 pb-20 lg:pt-48 lg:pb-32 overflow-hidden min-h-svh flex">
<!-- <div class="absolute inset-0 bg-grid-pattern opacity-[0.4] -z-10"></div> -->
<div
class=":m: absolute top-0 right-0 -translate-y-1/2 translate-x-1/2 w-[800px] h-[800px] bg-primary-light/40 rounded-full blur-3xl -z-10 mix-blend-multiply animate-pulse duration-1000">
</div>
<div
class=":m: absolute bottom-0 left-0 translate-y-1/2 -translate-x-1/2 w-[600px] h-[600px] bg-teal-100/50 rounded-full blur-3xl -z-10 mix-blend-multiply">
</div>
<div class="max-w-7xl m-auto px-4 sm:px-6 lg:px-8 text-center">
<h1
class="text-5xl md:text-7xl font-extrabold tracking-tight text-slate-900 mb-6 leading-[1.1] animate-backwards">
Video infrastructure for <br>
<span class="text-gradient">modern internet.</span>
</h1>
<p class="text-xl text-slate-500 max-w-2xl mx-auto mb-10 leading-relaxed animate-backwards delay-50">
Seamlessly host, encode, and stream video with our developer-first API.
Optimized for speed, built for scale.
</p>
<div class="flex flex-col sm:flex-row justify-center gap-4">
<RouterLink to="/get-started" class="flex btn btn-success !rounded-xl !p-4 press-animated">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="46 -286 524 580">
<path d="M56 284v-560L560 4 56 284z" fill="#fff" />
</svg>&nbsp;
Get Started
</RouterLink>
<RouterLink to="/docs" class="flex btn btn-outline-primary !rounded-xl">
<svg xmlns="http://www.w3.org/2000/svg" width="28" 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="#14a74b" />
<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="#fff" />
</svg>&nbsp;
Upload video
</RouterLink>
</div>
</div>
</section>
<section id="features" class="py-24 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="mb-16 md:text-center max-w-3xl mx-auto">
<h2 class="text-3xl font-bold text-slate-900 mb-4">Everything you need to ship video</h2>
<p class="text-lg text-slate-500">Focus on building your product. We'll handle the complex video
infrastructure.</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div
class=":m: md:col-span-2 bg-slate-50 rounded-2xl p-8 border border-slate-100 hover:border-primary/60 transition-all group overflow-hidden relative">
<div class="relative z-10">
<div
class="w-12 h-12 bg-white rounded-xl flex items-center justify-center mb-6 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="532" viewBox="-8 -258 529 532">
<path
d="M342 32c-2 69-16 129-35 172-10 23-22 40-32 49-10 10-16 11-19 11h-1c-3 0-9-1-19-11-10-9-22-26-32-49-19-43-33-103-35-172h173zm169 0c-9 103-80 188-174 219 30-51 50-129 53-219h121zm-390 0c3 89 23 167 53 218C80 219 11 134 2 32h119zm53-266c-30 51-50 129-53 218H2c9-102 78-186 172-218zm82-14c3 0 9 1 19 11 10 9 22 26 32 50 19 42 33 102 35 171H169c3-69 16-129 35-171 10-24 22-41 32-50s16-11 19-11h1zm81 13c94 31 165 116 174 219H390c-3-90-23-168-53-219z"
fill="#059669" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Global Edge Network</h3>
<p class="text-slate-500 max-w-md">Content delivered from 200+ PoPs worldwide. Automatic region
selection ensures the lowest latency for every viewer.</p>
</div>
<div class="absolute right-0 bottom-0 opacity-10 translate-x-1/4 translate-y-1/4">
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="-10 -258 532 532">
<path
d="M464 8c0-19-3-38-8-56l-27-5c-8-2-15 2-19 9-6 11-19 17-31 13l-14-5c-8-2-17 0-22 5-4 4-4 10 0 14l33 33c5 5 8 12 8 19 0 12-8 23-20 26l-6 1c-3 1-6 5-6 9v12c0 13-4 27-13 38l-25 34c-6 8-16 13-26 13-18 0-32-14-32-32V88c0-9-7-16-16-16h-32c-26 0-48-22-48-48V-4c0-13 6-24 16-32l39-30c6-4 13-6 20-6 3 0 7 1 10 2l32 10c7 3 15 3 22 1l36-9c10-2 17-11 17-22 0-8-5-16-13-20l-29-15c-3-2-8-1-11 2l-4 4c-4 4-11 7-17 7-4 0-8-1-11-3l-15-7c-7-4-15-2-20 4l-13 17c-6 7-16 8-22 1-3-2-5-6-5-10v-41c0-6-1-11-4-16l-10-18C102-154 48-79 48 8c0 115 93 208 208 208S464 123 464 8zM0 8c0-141 115-256 256-256S512-133 512 8 397 264 256 264 0 149 0 8z"
fill="#1e3050" />
</svg>
</div>
</div>
<div class=":m: md:row-span-2 bg-slate-900 rounded-2xl p-8 text-white relative overflow-hidden group">
<div class=":m: absolute inset-0 bg-gradient-to-b from-slate-800/50 to-transparent"></div>
<div class="relative z-10">
<div
class=":m: w-12 h-12 bg-white/10 rounded-xl flex items-center justify-center mb-6 backdrop-blur-sm border border-white/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -146 468 384">
<path
d="M392-136c-31 0-56 25-56 56v280c0 16 13 28 28 28h28c31 0 56-25 56-56V-80c0-31-25-56-56-56zM168 4c0-31 25-56 56-56h28c16 0 28 13 28 28v224c0 16-12 28-28 28h-56c-15 0-28-12-28-28V4zM0 88c0-31 25-56 56-56h28c16 0 28 13 28 28v140c0 16-12 28-28 28H56c-31 0-56-25-56-56V88z"
fill="#fff" />
</svg>
</div>
<h3 class="text-xl font-bold mb-2">Live Streaming API</h3>
<p class="text-slate-400 text-sm leading-relaxed mb-8">Scale to millions of concurrent viewers
with ultra-low latency. RTMP ingest and HLS playback supported natively.</p>
<!-- Visual -->
<div
class="bg-slate-800/50 rounded-lg p-4 border border-white/5 font-mono text-xs text-brand-300">
<div class="flex justify-between items-center mb-3 border-b border-white/5 pb-2">
<span class="text-slate-500">Live Status</span>
<span
class=":m: flex items-center gap-1.5 text-red-500 text-[10px] uppercase font-bold tracking-wider animate-pulse"><span
class="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse"></span> On Air</span>
</div>
<div class="space-y-1">
<div class="flex justify-between"><span class="text-slate-400">Bitrate:</span> <span
class="text-white">6000 kbps</span></div>
<div class="flex justify-between"><span class="text-slate-400">FPS:</span> <span
class="text-white">60</span></div>
<div class="flex justify-between"><span class="text-slate-400">Latency:</span> <span
class="text-brand-400">~2s</span></div>
</div>
</div>
</div>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-purple-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="0 0 570 570">
<path
d="M50 428c-5 5-5 14 0 19s14 5 19 0l237-237c5-5 5-14 0-19s-14-5-19 0L50 428zm16-224c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zM174 60c-5 5-5 13 0 19 5 5 14 5 19 0l12-12c5-5 5-14 0-19-6-5-14-5-20 0l-11 12zm215 29c-5 5-5 14 0 19s14 5 19 0l39-39c5-5 5-14 0-19s-14-5-19 0l-39 39zm21 357c-5 5-5 14 0 19s14 5 19 0l18-18c5-5 5-14 0-19s-14-5-19 0l-18 18z"
fill="#a6acb9" />
<path
d="M170 26c14-15 36-15 50 0l18 18c15 14 15 36 0 50l-18 18c-14 15-36 15-50 0l-18-18c-15-14-15-36 0-50l18-18zm35 41c5-5 5-14 0-19-6-5-14-5-20 0l-11 12c-5 5-5 13 0 19 5 5 14 5 19 0l12-12zm204 342c21-21 55-21 76 0l18 18c21 21 21 55 0 76l-18 18c-21 21-55 21-76 0l-18-18c-21-21-21-55 0-76l18-18zm38 38c5-5 5-14 0-19s-14-5-19 0l-18 18c-5 5-5 14 0 19s14 5 19 0l18-18zM113 170c-15-15-37-15-51 0l-18 18c-14 14-14 36 0 50l18 18c14 15 37 15 51 0l18-18c14-14 14-36 0-50l-18-18zm-16 41-12 12c-5 5-14 5-19 0-5-6-5-14 0-20l11-11c6-5 14-5 20 0 5 5 5 14 0 19zM485 31c-21-21-55-21-76 0l-39 39c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l39-39c21-21 21-55 0-76l-54-54zm-38 38-39 39c-5 5-14 5-19 0s-5-14 0-19l39-39c5-5 14-5 19 0s5 14 0 19zm-49 233c21-21 21-55 0-76l-54-54c-21-21-55-21-76 0L31 409c-21 21-21 55 0 76l54 54c21 21 55 21 76 0l237-237zm-92-92L69 447c-5 5-14 5-19 0s-5-14 0-19l237-237c5-5 14-5 19 0s5 14 0 19z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Instant Encoding</h3>
<p class="text-slate-500 text-sm">Upload raw files and get optimized HLS/DASH streams in seconds.
</p>
</div>
<!-- Standard Feature -->
<div
class=":m: bg-slate-50 rounded-2xl p-8 border border-slate-100 transition-all group hover:(border-brand-200 shadow-lg shadow-brand-500/5)">
<div
class=":m: w-12 h-12 bg-white rounded-xl shadow-sm flex items-center justify-center mb-6 text-orange-600 border border-slate-100">
<svg xmlns="http://www.w3.org/2000/svg" width="24" viewBox="-10 -226 532 468">
<path
d="M32-216c18 0 32 14 32 32v336c0 9 7 16 16 16h400c18 0 32 14 32 32s-14 32-32 32H80c-44 0-80-36-80-80v-336c0-18 14-32 32-32zM144-24c18 0 32 14 32 32v64c0 18-14 32-32 32s-32-14-32-32V8c0-18 14-32 32-32zm144-64V72c0 18-14 32-32 32s-32-14-32-32V-88c0-18 14-32 32-32s32 14 32 32zm80 32c18 0 32 14 32 32v96c0 18-14 32-32 32s-32-14-32-32v-96c0-18 14-32 32-32zm144-96V72c0 18-14 32-32 32s-32-14-32-32v-224c0-18 14-32 32-32s32 14 32 32z"
fill="#1e3050" />
</svg>
</div>
<h3 class="text-xl font-bold text-slate-900 mb-2">Deep Analytics</h3>
<p class="text-slate-500 text-sm">Session-level insights, quality of experience (QoE) metrics, and
more.</p>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 border-t border-slate-100 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center mb-16">
<h2 class="text-3xl font-bold text-slate-900 mb-4">{{ pricing.title }}</h2>
<p class="text-slate-500">{{ pricing.subtitle }}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8 w-full">
<div v-for="pack in pricing.packs" :key="pack.name"
:class="cn(':uno: p-8 rounded-2xl relative overflow-hidden hover:border-primary transition-colors flex flex-col justify-between', pack.tag == 'POPULAR' ? 'border-primary/80 border-2' : 'border-slate-200 border')"
:style="{ background: pack.bg }">
<div v-if="pack.tag"
class=":m: absolute top-0 right-0 bg-primary/80 text-white text-xs font-bold px-3 py-1 rounded-bl-lg uppercase">
{{ pack.tag }}</div>
<div>
<h3 class="font-semibold text-slate-900 text-xl mb-2">{{ pack.name }}</h3>
<div class="flex items-baseline gap-1 mb-6">
<span class="text-4xl font-bold text-slate-900">{{ pack.price }}</span>
<span class="text-slate-500">/mo</span>
</div>
</div>
<ul class="space-y-3 mb-8 text-sm text-slate-600">
<li v-for="value in pack.features" :key="value" class="flex items-center gap-3"><Check-Icon
class="fas fa-check text-brand-500" /> {{ value }}</li>
</ul>
<router-link to="/sign-up"
:class="cn('btn flex justify-center w-full !py-2.5', pack.tag == 'POPULAR' ? 'btn-primary' : 'btn-outline-primary')">{{
pack.buttonText }}</router-link>
</div>
</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { cn } from '@/client/lib/utils';
const pricing = {
title: "Simple, transparent pricing",
subtitle: "Choose the plan that fits your needs. No hidden fees.",
packs: [
{
name: "Hobby",
price: "$0",
features: [
"Unlimited upload",
"1 Hour of Storage",
"Standard Support",
],
buttonText: "Start Free",
tag: "",
bg: "#f9fafb",
},
{
name: "Pro",
price: "$29",
features: [
"Ads free player",
"Support M3U8",
"Unlimited upload",
"Custom ads"
],
buttonText: "Get Started",
tag: "POPULAR",
bg: "#eff6ff",
},
{
name: "Scale",
price: "$99",
features: [
"5 TB Bandwidth",
"500 Hours Storage",
"Priority Support"
],
buttonText: "Contact Sales",
tag: "Best Value",
bg: "#eef4f7",
}
]
}
</script>

View File

@@ -5,21 +5,21 @@
<div class="flex items-center justify-between h-16">
<router-link to="/" class="flex items-center gap-2 cursor-pointer">
<img class="h-8 w-8" src="/apple-touch-icon.png" alt="Logo" />
<span class="font-bold text-xl tracking-tight text-slate-900">{{ t('app.name') }}</span>
<span class="font-bold text-xl tracking-tight text-slate-900">EcoStream</span>
</router-link>
<div class="hidden md:flex items-center space-x-8">
<a href="#features"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">{{ t('home.nav.features') }}</a>
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Features</a>
<a href="#pricing"
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">{{ t('home.nav.pricing') }}</a>
class="text-sm font-medium text-slate-600 hover:text-brand-600 transition-colors">Pricing</a>
</div>
<div class="hidden md:flex items-center gap-4">
<RouterLink to="/login"
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">{{ t('home.nav.login') }}
class="text-sm font-semibold text-slate-600 hover:text-slate-900 cursor-pointer">Log in
</RouterLink>
<RouterLink to="/sign-up"
class="bg-slate-900 hover:bg-black text-white px-5 py-2.5 rounded-lg text-sm font-semibold cursor-pointer">
{{ t('home.nav.startFree') }}
Start for free
</RouterLink>
</div>
</div>
@@ -38,49 +38,47 @@
<div class="w-6 h-6 bg-brand-600 rounded flex items-center justify-center text-white">
<img class="h-6 w-6" src="/apple-touch-icon.png" alt="Logo" />
</div>
<span class="font-bold text-lg text-slate-900">{{ t('app.name') }}</span>
<span class="font-bold text-lg text-slate-900">EcoStream</span>
</div>
<p class="text-slate-500 text-sm max-w-xs">{{ t('home.footer.description') }}</p>
<p class="text-slate-500 text-sm max-w-xs">Building the video layer of the internet. Designed for
developers.</p>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.product') }}</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Product</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productFeatures') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productPricing') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.productShowcase') }}</a></li>
<li><a href="#" class="hover:text-brand-600">Features</a></li>
<li><a href="#" class="hover:text-brand-600">Pricing</a></li>
<li><a href="#" class="hover:text-brand-600">Showcase</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.company') }}</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Company</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyAbout') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyBlog') }}</a></li>
<li><a href="#" class="hover:text-brand-600">{{ t('home.footer.companyCareers') }}</a></li>
<li><a href="#" class="hover:text-brand-600">About</a></li>
<li><a href="#" class="hover:text-brand-600">Blog</a></li>
<li><a href="#" class="hover:text-brand-600">Careers</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">{{ t('home.footer.legal') }}</h4>
<h4 class="font-semibold text-slate-900 mb-4 text-sm">Legal</h4>
<ul class="space-y-2 text-sm text-slate-500">
<li><router-link to="/privacy" class="hover:text-brand-600">{{ t('home.footer.privacy') }}</router-link></li>
<li><router-link to="/terms" class="hover:text-brand-600">{{ t('home.footer.terms') }}</router-link></li>
<li><router-link to="/privacy" class="hover:text-brand-600">Privacy</router-link></li>
<li><router-link to="/terms" class="hover:text-brand-600">Terms</router-link></li>
</ul>
</div>
</div>
<div class="pt-8 border-t border-slate-100 text-center text-sm text-slate-400">
{{ t('home.footer.copyright', { year: 2026 }) }}
&copy; 2026 EcoStream Inc. All rights reserved.
</div>
</div>
</footer>
<Head>
<title>{{ t('home.head.title') }}</title>
<title>EcoStream - Video infrastructure for modern internet</title>
<meta name="description"
:content="t('home.head.description')" />
content="Seamlessly host, encode, and stream video with our developer-first API. Optimized for speed, built for scale." />
</Head>
</template>
<script lang="ts" setup>
import { Head } from '@unhead/vue/components'
import { useTranslation } from 'i18next-vue';
const { t } = useTranslation();
</script>

View File

@@ -0,0 +1,61 @@
<template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
<div class="grow pt-32 pb-12 px-4">
<div class="max-w-4xl mx-auto space-y-10">
<div class="space-y-3">
<p
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
</div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Privacy Policy - Ecostream";
const description = "Read about Ecostream's commitment to protecting your privacy and data security.";
const pageContent = {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
]
},
data: {
pageHeading: "Legal & Privacy Policy",
pageSubheading: "Legal & Privacy Policy",
description: "Our legal and privacy policy.",
list: [{
heading: "1. Privacy Policy",
text: "At Ecostream, we take your privacy seriously. This policy describes how we collect, use, and protect your personal information. We only collect information that is necessary for the operation of our service, including email addresses for account creation and payment information for subscription processing."
},
{
heading: "2. Data Collection",
text: "We collect data such as IP addresses, browser types, and access times to analyze trends and improve our service. Uploaded content is stored securely and is only accessed as required for the delivery of our hosting services."
},
{
heading: "3. Cookie Policy",
text: "We use cookies to maintain user sessions and preferences. By using our website, you consent to the use of cookies in accordance with this policy."
},
{
heading: "4. DMCA & Copyright",
text: "Ecostream respects the intellectual property rights of others. We respond to notices of alleged copyright infringement in accordance with the Digital Millennium Copyright Act (DMCA). Please report any copyright violations to our support team."
}]
}
}
useHead(pageContent.head);
</script>

View File

@@ -0,0 +1,67 @@
<template>
<div class="max-w-4xl mx-auto space-y-10" style="opacity: 1; transform: none;">
<div class="grow pt-32 pb-12 px-4">
<div class="max-w-4xl mx-auto space-y-10">
<div class="space-y-3">
<p
class="inline-block px-4 py-1.5 rounded-full bg-info/20 font-bold text-sm uppercase">
{{ pageContent.data.pageSubheading }}</p>
<h1 class="text-4xl md:text-5xl font-heading font-extrabold">{{ pageContent.data.pageHeading }}</h1>
<p class="text-slate-600 text-lg font-medium">{{ pageContent.data.description }}</p>
</div>
<div class="bg-white p-8 rounded-xl border border-gray-200 shadow-hard space-y-6">
<section v-for="(item, index) in pageContent.data.list" :key="index">
<h2 class="text-2xl font-bold mb-4">{{ item.heading }}</h2>
<p class="leading-relaxed">{{ item.text }}</p>
</section>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import {useHead} from "@unhead/vue";
const title = "Terms and Conditions - Ecostream";
const description = "Read Ecostream's terms and conditions for using our video hosting and streaming services.";
const pageContent = {
head: {
title,
meta: [
{ name: "description", content: description },
{ property: "og:title", content: title },
{ property: "og:description", content: description },
{ property: "twitter:title", content: title },
{ property: "twitter:description", content: description },
{ property: "twitter:image", content: "https://Ecostream.com/thumb.png" }
]
},
data: {
pageHeading: "Terms and Conditions Details",
pageSubheading: "Terms and Conditions",
description: "Our terms and conditions set forth important guidelines and rules for using Ecostream's services.",
list: [
{
heading: "1. Acceptance of Terms",
text: "By accessing and using Ecostream, you accept and agree to be bound by the terms and provision of this agreement."
},
{
heading: "2. Service Usage",
text: "You agree to use our service only for lawful purposes. You are prohibited from posting or transmitting any unlawful, threatening, libelous, defamatory, obscene, or profane material. We reserve the right to terminate accounts that violate these terms."
},
{
heading: "3. Content Ownership",
text: "You retain all rights and ownership of the content you upload to Ecostream. However, by uploading content, you grant us a license to host, store, and display the content as necessary to provide our services."
},
{
heading: "4. Limitation of Liability",
text: "Ecostream shall not be liable for any direct, indirect, incidental, special, or consequential damages resulting from the use or inability to use our service."
},
{
heading: "5. Changes to Terms",
text: "We reserve the right to modify these terms at any time. Your continued use of the service after any such changes constitutes your acceptance of the new terms."
}
]
}
}
useHead(pageContent.head);
</script>

86
src/client/stores/auth.ts Normal file
View File

@@ -0,0 +1,86 @@
import { client } from '@/client/api/rpcclient';
import { User } from 'firebase/auth';
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { emailAuth, signUp } from '../lib/firebase';
export const useAuthStore = defineStore('auth', () => {
const user = ref<User | null>(null);
const router = useRouter();
const loading = ref(false);
const error = ref<string | null>(null);
const csrfToken = ref<string | null>(null);
const initialized = ref(false);
// Check auth status on init (reads from cookie)
async function init() {
if (initialized.value) return;
// try {
// const response = await client.checkAuth();
// if (response.authenticated && response.user) {
// user.value = response.user;
// // Get CSRF token if authenticated
// try {
// const csrfResponse = await client.getCSRFToken();
// csrfToken.value = csrfResponse.csrfToken;
// } catch (e) {
// // CSRF token might not be available yet
// }
// }
// } catch (e) {
// // Not authenticated, that's fine
// } finally {
// initialized.value = true;
// }
}
async function login(username: string, password: string) {
loading.value = true;
error.value = null;
return emailAuth(username, password).then((userCredential) => {
user.value = userCredential.user;
// csrfToken.value = userCredential.csrfToken;
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Login failed';
error.value = 'Login failed';
throw e;
}).finally(() => {
loading.value = false;
});
}
async function register(username: string, email: string, password: string) {
loading.value = true;
error.value = null;
return signUp(email, password).then((response) => {
user.value = response.user;
csrfToken.value = response.csrfToken;
router.push('/');
}).catch((e: any) => {
// error.value = e.message || 'Registration failed';
error.value = 'Registration failed';
throw e;
}).finally(() => {
loading.value = false;
});
}
async function logout() {
return client.logout().then(() => {
user.value = null;
csrfToken.value = null;
router.push('/');
})
}
return { user, loading, error, csrfToken, initialized, init, login, register, logout, $reset: () => {
user.value = null;
loading.value = false;
error.value = null;
csrfToken.value = null;
initialized.value = false;
} };
});

View File

@@ -0,0 +1,63 @@
import { createContext, jsx, Suspense } from "hono/jsx";
import { renderToReadableStream, StreamingContext } from "hono/jsx/streaming";
import { HtmlEscapedCallback, HtmlEscapedString, raw } from "hono/utils/html";
// import { jsxs } from "hono/jsx-renderer";
import { Context } from "hono";
import type {
FC,
Context as JSXContext,
JSXNode
} from "hono/jsx";
import { jsxTemplate } from "hono/jsx/jsx-runtime";
export const RequestContext: JSXContext<Context<any, any, {}> | null> =
createContext<Context | null>(null);
export function renderSSRLayout(c: Context, appStream: ReadableStream) {
const body = jsxTemplate`${raw("<!DOCTYPE html>")}${_c(
RequestContext.Provider,
{ value: c },
// currentLayout as any
_c(
"html",
{ lang: "en" },
_c(
"head",
null,
raw('<meta charset="UTF-8"/>'),
raw('<meta name="viewport" content="width=device-width, initial-scale=1.0"/>'),
raw('<link rel="icon" href="/favicon.ico" />'),
raw(`<base href="${new URL(c.req.url).origin}/"/>`)
),
_c(
"body",
{
class:
"font-sans bg-[#f9fafd] text-gray-800 antialiased flex flex-col",
},
_c(
StreamingContext,
{ value: { scriptNonce: "random-nonce-value" } },
_c(
Suspense,
{ fallback: _c("div", { class: "loading" }, raw("Loading...")) },
raw(appStream.getReader())
)
),
_c("script", {
dangerouslySetInnerHTML: {
__html: `window.__SSR_STATE__ = ${JSON.stringify(
JSON.stringify(c.get("ssrContext") || {})
)};`,
},
})
)
)
)}`;
return renderToReadableStream(body);
}
function _c(
tag: string | FC<any>,
props: any,
...children: (JSXNode | HtmlEscapedCallback | HtmlEscapedString | null)[]
): JSXNode {
return jsx(tag, props, ...(children as any));
}

View File

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

View File

@@ -1,25 +0,0 @@
// 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;

View File

@@ -1,26 +0,0 @@
<script lang="ts" setup>
import Upload from "@/routes/upload/Upload.vue";
import DashboardNav from "./DashboardNav.vue";
import GlobalUploadIndicator from "./GlobalUploadIndicator.vue";
</script>
<template>
<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"
enter-to-class="opacity-100 transform translate-y-0"
leave-active-class="transition-all duration-200 ease-in-out"
leave-from-class="opacity-100 transform translate-y-0"
leave-to-class="opacity-0 transform -translate-y-4" mode="out-in">
<component :is="Component" />
</Transition>
</router-view>
</div>
<GlobalUploadIndicator />
<Upload />
</main>
</template>

View File

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

View File

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

View File

@@ -1,116 +0,0 @@
<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) => {
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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 518"><path d="M234 124v256c58 3 113 25 156 63l47 41c9 8 23 10 34 5 12-5 19-16 19-29V44c0-13-7-24-19-29-11-5-25-3-34 5l-47 41c-43 38-98 60-156 63z" fill="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>

View File

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

View File

@@ -1,16 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="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>

View File

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

View File

@@ -1,15 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="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>

View File

@@ -1,11 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 468 532"><path d="M10 391c0 19 16 35 36 35h376c20 0 36-16 36-35 0-9-3-16-8-23l-10-12c-30-37-46-84-46-132v-22c0-77-55-142-128-157v-3c0-18-14-32-32-32s-32 14-32 32v3C129 60 74 125 74 202v22c0 48-16 95-46 132l-10 12c-5 7-8 14-8 23z" fill="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="currentColor" />
</svg>
</template>
<script lang="ts" setup>
defineProps<{ filled?: boolean }>();
</script>

View File

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

View File

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

View File

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

View File

@@ -1,16 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="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>

View File

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

View File

@@ -1,14 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path d="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>

View File

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

View File

@@ -1,7 +0,0 @@
<template>
<svg xmlns="http://www.w3.org/2000/svg" v-if="filled" viewBox="0 0 532 404"><path d="M10 74c0-35 29-64 64-64h384c35 0 64 29 64 64v32H10V74zm0 96h512v160c0 35-29 64-64 64H74c-35 0-64-29-64-64V170zm64 136c0 13 11 24 24 24h48c13 0 24-11 24-24s-11-24-24-24H98c-13 0-24 11-24 24zm144 0c0 13 11 24 24 24h64c13 0 24-11 24-24s-11-24-24-24h-64c-13 0-24 11-24 24z" fill="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<{ filled?: boolean }>();
</script>

View File

@@ -1,16 +0,0 @@
<template>
<svg v-if="filled" xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor"
stroke="none">
<path
d="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>

View File

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

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