mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 15:49:40 +02:00
style: auto-format codebase with Prettier
Applied formatting to 1487+ files using pnpm format:write - TypeScript/JavaScript files - Svelte components - Astro pages - JSON configs - Markdown docs 13 files still need manual review (Astro JSX comments)
This commit is contained in:
parent
0241f5554c
commit
d36b321d9d
3952 changed files with 661498 additions and 739751 deletions
|
|
@ -5,6 +5,7 @@ This file provides guidance to Claude Code when working with the Wisekeep projec
|
|||
## Project Overview
|
||||
|
||||
Wisekeep is an AI-powered wisdom extraction application that captures insights from video content:
|
||||
|
||||
- YouTube video download via yt-dlp
|
||||
- Ultra-fast audio transcription using Groq Whisper API (~300x realtime)
|
||||
- Fallback to local Whisper for offline use
|
||||
|
|
@ -32,6 +33,7 @@ apps/wisekeep/
|
|||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20+
|
||||
- pnpm 9.15.0+
|
||||
- yt-dlp installed (`brew install yt-dlp` on macOS)
|
||||
|
|
@ -59,6 +61,7 @@ pnpm dev:wisekeep:app
|
|||
### Environment Variables
|
||||
|
||||
Create `apps/wisekeep/apps/backend/.env`:
|
||||
|
||||
```bash
|
||||
PORT=3006
|
||||
WHISPER_PROVIDER=groq # groq or local
|
||||
|
|
@ -72,33 +75,37 @@ PLAYLISTS_DIR=./data/playlists
|
|||
## API Endpoints
|
||||
|
||||
### Transcription
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| POST | `/transcription` | Start new transcription job |
|
||||
| GET | `/transcription` | List all jobs |
|
||||
| GET | `/transcription/:id` | Get job status |
|
||||
| DELETE | `/transcription/:id` | Cancel job |
|
||||
| GET | `/transcription/stats` | Get statistics |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ---------------------- | --------------------------- |
|
||||
| POST | `/transcription` | Start new transcription job |
|
||||
| GET | `/transcription` | List all jobs |
|
||||
| GET | `/transcription/:id` | Get job status |
|
||||
| DELETE | `/transcription/:id` | Cancel job |
|
||||
| GET | `/transcription/stats` | Get statistics |
|
||||
|
||||
### Playlists
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/playlist` | List all playlists |
|
||||
| GET | `/playlist/:category/:name` | Get specific playlist |
|
||||
| POST | `/playlist` | Create playlist |
|
||||
| DELETE | `/playlist/:category/:name` | Delete playlist |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------- | --------------------- |
|
||||
| GET | `/playlist` | List all playlists |
|
||||
| GET | `/playlist/:category/:name` | Get specific playlist |
|
||||
| POST | `/playlist` | Create playlist |
|
||||
| DELETE | `/playlist/:category/:name` | Delete playlist |
|
||||
|
||||
### Whisper
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/whisper/models` | Get available models |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------- | -------------------- |
|
||||
| GET | `/whisper/models` | Get available models |
|
||||
|
||||
### Health
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/health/ready` | Readiness check |
|
||||
| GET | `/health/live` | Liveness check |
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------- | --------------- |
|
||||
| GET | `/health` | Health check |
|
||||
| GET | `/health/ready` | Readiness check |
|
||||
| GET | `/health/live` | Liveness check |
|
||||
|
||||
## WebSocket
|
||||
|
||||
|
|
@ -108,21 +115,22 @@ Connect to `/progress` namespace for real-time updates:
|
|||
const socket = io('http://localhost:3006/progress');
|
||||
|
||||
socket.on('job_update', (data) => {
|
||||
// { type, jobId, status, progress, videoInfo }
|
||||
// { type, jobId, status, progress, videoInfo }
|
||||
});
|
||||
|
||||
socket.on('job_complete', (data) => {
|
||||
// { type, jobId, status, transcriptPath }
|
||||
// { type, jobId, status, transcriptPath }
|
||||
});
|
||||
|
||||
socket.on('job_error', (data) => {
|
||||
// { type, jobId, error }
|
||||
// { type, jobId, error }
|
||||
});
|
||||
```
|
||||
|
||||
## Whisper Configuration
|
||||
|
||||
### Groq Whisper API (Recommended)
|
||||
|
||||
- Ultra-fast, cloud-based (~300x realtime speed)
|
||||
- Cost: ~$0.04/hour (whisper-large-v3-turbo) or ~$0.111/hour (whisper-large-v3)
|
||||
- No GPU required
|
||||
|
|
@ -130,6 +138,7 @@ socket.on('job_error', (data) => {
|
|||
- Set `WHISPER_PROVIDER=groq` and `GROQ_API_KEY`
|
||||
|
||||
### Local Whisper
|
||||
|
||||
- Free, runs locally
|
||||
- Requires Python + openai-whisper
|
||||
- GPU recommended for larger models
|
||||
|
|
@ -138,51 +147,55 @@ socket.on('job_error', (data) => {
|
|||
|
||||
## Technology Stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Backend | NestJS 10, TypeScript |
|
||||
| Web | SvelteKit 2, Svelte 5, Tailwind |
|
||||
| Landing | Astro 4, Tailwind |
|
||||
| Mobile | Expo 52, React Native, NativeWind |
|
||||
| YouTube | yt-dlp (via child_process) |
|
||||
| Transcription | Groq Whisper API / local Whisper |
|
||||
| Real-time | Socket.io |
|
||||
| State (Mobile) | Zustand |
|
||||
| Component | Technology |
|
||||
| -------------- | --------------------------------- |
|
||||
| Backend | NestJS 10, TypeScript |
|
||||
| Web | SvelteKit 2, Svelte 5, Tailwind |
|
||||
| Landing | Astro 4, Tailwind |
|
||||
| Mobile | Expo 52, React Native, NativeWind |
|
||||
| YouTube | yt-dlp (via child_process) |
|
||||
| Transcription | Groq Whisper API / local Whisper |
|
||||
| Real-time | Socket.io |
|
||||
| State (Mobile) | Zustand |
|
||||
|
||||
## Code Patterns
|
||||
|
||||
### Backend Services
|
||||
|
||||
```typescript
|
||||
@Injectable()
|
||||
export class TranscriptionService {
|
||||
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
|
||||
// Background processing with WebSocket updates
|
||||
}
|
||||
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
|
||||
// Background processing with WebSocket updates
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Web (Svelte 5 Runes)
|
||||
|
||||
```typescript
|
||||
// Correct - Svelte 5
|
||||
let jobs = $state<Job[]>([]);
|
||||
let activeJobs = $derived(jobs.filter(j => j.status === 'active'));
|
||||
let activeJobs = $derived(jobs.filter((j) => j.status === 'active'));
|
||||
|
||||
// Wrong - Old Svelte syntax
|
||||
let jobs = [];
|
||||
$: activeJobs = jobs.filter(j => j.status === 'active');
|
||||
$: activeJobs = jobs.filter((j) => j.status === 'active');
|
||||
```
|
||||
|
||||
### Mobile (Zustand)
|
||||
|
||||
```typescript
|
||||
export const useJobStore = create<JobStore>((set) => ({
|
||||
jobs: [],
|
||||
addJob: (job) => set((state) => ({ jobs: [...state.jobs, job] })),
|
||||
jobs: [],
|
||||
addJob: (job) => set((state) => ({ jobs: [...state.jobs, job] })),
|
||||
}));
|
||||
```
|
||||
|
||||
## Legacy Python Code
|
||||
|
||||
The original Python implementation is preserved in `legacy/` for reference:
|
||||
|
||||
- `transcriber_v4_parallel.py` - Main transcription logic
|
||||
- `api_server.py` - FastAPI server (replaced by NestJS)
|
||||
- `requirements.txt` - Python dependencies
|
||||
|
|
@ -190,6 +203,7 @@ The original Python implementation is preserved in `legacy/` for reference:
|
|||
## Troubleshooting
|
||||
|
||||
### yt-dlp not found
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install yt-dlp
|
||||
|
|
@ -199,6 +213,7 @@ pip install yt-dlp
|
|||
```
|
||||
|
||||
### Local Whisper not working
|
||||
|
||||
```bash
|
||||
# Install Whisper
|
||||
pip install openai-whisper
|
||||
|
|
@ -208,6 +223,7 @@ python3 -c "import whisper; print(whisper.available_models())"
|
|||
```
|
||||
|
||||
### Backend can't start
|
||||
|
||||
```bash
|
||||
# Check port 3006
|
||||
lsof -i :3006 && kill -9 $(lsof -t -i:3006)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,52 @@
|
|||
{
|
||||
"name": "@wisekeep/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep Backend - NestJS API for wisdom extraction",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-socket.io": "^10.4.15",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"openai": "^4.73.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
||||
"@typescript-eslint/parser": "^8.17.0",
|
||||
"eslint": "^9.16.0",
|
||||
"jest": "^29.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@wisekeep/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep Backend - NestJS API for wisdom extraction",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-socket.io": "^10.4.15",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"openai": "^4.73.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
||||
"@typescript-eslint/parser": "^8.17.0",
|
||||
"eslint": "^9.16.0",
|
||||
"jest": "^29.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,17 +8,17 @@ import { WebsocketModule } from './websocket/websocket.module';
|
|||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
TranscriptionModule,
|
||||
PlaylistModule,
|
||||
YoutubeModule,
|
||||
WhisperModule,
|
||||
WebsocketModule,
|
||||
HealthModule,
|
||||
],
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: '.env',
|
||||
}),
|
||||
TranscriptionModule,
|
||||
PlaylistModule,
|
||||
YoutubeModule,
|
||||
WhisperModule,
|
||||
WebsocketModule,
|
||||
HealthModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,29 @@ import { Controller, Get } from '@nestjs/common';
|
|||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'transcriber-backend',
|
||||
version: '1.0.0',
|
||||
};
|
||||
}
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'transcriber-backend',
|
||||
version: '1.0.0',
|
||||
};
|
||||
}
|
||||
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@Get('ready')
|
||||
ready() {
|
||||
return {
|
||||
status: 'ready',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'alive',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@Get('live')
|
||||
live() {
|
||||
return {
|
||||
status: 'alive',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,6 @@ import { Module } from '@nestjs/common';
|
|||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
|
|||
|
|
@ -3,29 +3,29 @@ import { ValidationPipe } from '@nestjs/common';
|
|||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:5173', // SvelteKit dev
|
||||
'http://localhost:4321', // Astro dev
|
||||
'http://localhost:3000', // Alternative dev
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:5173', // SvelteKit dev
|
||||
'http://localhost:4321', // Astro dev
|
||||
'http://localhost:3000', // Alternative dev
|
||||
],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3006;
|
||||
await app.listen(port);
|
||||
const port = process.env.PORT || 3006;
|
||||
await app.listen(port);
|
||||
|
||||
console.log(`[Transcriber Backend] Running on http://localhost:${port}`);
|
||||
console.log(`[Transcriber Backend] Running on http://localhost:${port}`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
|
|
|||
|
|
@ -1,50 +1,37 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common';
|
||||
import { PlaylistService, CreatePlaylistDto } from './playlist.service';
|
||||
|
||||
@Controller('playlist')
|
||||
export class PlaylistController {
|
||||
constructor(private readonly playlistService: PlaylistService) {}
|
||||
constructor(private readonly playlistService: PlaylistService) {}
|
||||
|
||||
@Get()
|
||||
async getAll() {
|
||||
return this.playlistService.getAll();
|
||||
}
|
||||
@Get()
|
||||
async getAll() {
|
||||
return this.playlistService.getAll();
|
||||
}
|
||||
|
||||
@Get(':category/:name')
|
||||
async getOne(
|
||||
@Param('category') category: string,
|
||||
@Param('name') name: string,
|
||||
) {
|
||||
return this.playlistService.getOne(category, name);
|
||||
}
|
||||
@Get(':category/:name')
|
||||
async getOne(@Param('category') category: string, @Param('name') name: string) {
|
||||
return this.playlistService.getOne(category, name);
|
||||
}
|
||||
|
||||
@Post()
|
||||
async create(@Body() dto: CreatePlaylistDto) {
|
||||
return this.playlistService.create(dto);
|
||||
}
|
||||
@Post()
|
||||
async create(@Body() dto: CreatePlaylistDto) {
|
||||
return this.playlistService.create(dto);
|
||||
}
|
||||
|
||||
@Delete(':category/:name')
|
||||
async delete(
|
||||
@Param('category') category: string,
|
||||
@Param('name') name: string,
|
||||
) {
|
||||
await this.playlistService.delete(category, name);
|
||||
return { message: 'Playlist deleted' };
|
||||
}
|
||||
@Delete(':category/:name')
|
||||
async delete(@Param('category') category: string, @Param('name') name: string) {
|
||||
await this.playlistService.delete(category, name);
|
||||
return { message: 'Playlist deleted' };
|
||||
}
|
||||
|
||||
@Post(':category/:name/url')
|
||||
async addUrl(
|
||||
@Param('category') category: string,
|
||||
@Param('name') name: string,
|
||||
@Body('url') url: string,
|
||||
) {
|
||||
return this.playlistService.addUrl(category, name, url);
|
||||
}
|
||||
@Post(':category/:name/url')
|
||||
async addUrl(
|
||||
@Param('category') category: string,
|
||||
@Param('name') name: string,
|
||||
@Body('url') url: string
|
||||
) {
|
||||
return this.playlistService.addUrl(category, name, url);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { PlaylistController } from './playlist.controller';
|
|||
import { PlaylistService } from './playlist.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PlaylistController],
|
||||
providers: [PlaylistService],
|
||||
exports: [PlaylistService],
|
||||
controllers: [PlaylistController],
|
||||
providers: [PlaylistService],
|
||||
exports: [PlaylistService],
|
||||
})
|
||||
export class PlaylistModule {}
|
||||
|
|
|
|||
|
|
@ -4,173 +4,170 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
|
||||
export interface Playlist {
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
urlCount: number;
|
||||
urls: string[];
|
||||
description?: string;
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
urlCount: number;
|
||||
urls: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CreatePlaylistDto {
|
||||
name: string;
|
||||
description?: string;
|
||||
urls: string[];
|
||||
name: string;
|
||||
description?: string;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PlaylistService {
|
||||
private readonly logger = new Logger(PlaylistService.name);
|
||||
private readonly playlistsDir: string;
|
||||
private readonly logger = new Logger(PlaylistService.name);
|
||||
private readonly playlistsDir: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.playlistsDir =
|
||||
this.configService.get<string>('PLAYLISTS_DIR') || './data/playlists';
|
||||
constructor(private configService: ConfigService) {
|
||||
this.playlistsDir = this.configService.get<string>('PLAYLISTS_DIR') || './data/playlists';
|
||||
|
||||
// Ensure playlists directory exists
|
||||
if (!fs.existsSync(this.playlistsDir)) {
|
||||
fs.mkdirSync(this.playlistsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
// Ensure playlists directory exists
|
||||
if (!fs.existsSync(this.playlistsDir)) {
|
||||
fs.mkdirSync(this.playlistsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async getAll(): Promise<Playlist[]> {
|
||||
const playlists: Playlist[] = [];
|
||||
async getAll(): Promise<Playlist[]> {
|
||||
const playlists: Playlist[] = [];
|
||||
|
||||
if (!fs.existsSync(this.playlistsDir)) {
|
||||
return playlists;
|
||||
}
|
||||
if (!fs.existsSync(this.playlistsDir)) {
|
||||
return playlists;
|
||||
}
|
||||
|
||||
const categories = fs
|
||||
.readdirSync(this.playlistsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
const categories = fs
|
||||
.readdirSync(this.playlistsDir, { withFileTypes: true })
|
||||
.filter((d) => d.isDirectory());
|
||||
|
||||
for (const category of categories) {
|
||||
const categoryPath = path.join(this.playlistsDir, category.name);
|
||||
const files = fs
|
||||
.readdirSync(categoryPath)
|
||||
.filter((f) => f.endsWith('.txt'));
|
||||
for (const category of categories) {
|
||||
const categoryPath = path.join(this.playlistsDir, category.name);
|
||||
const files = fs.readdirSync(categoryPath).filter((f) => f.endsWith('.txt'));
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(categoryPath, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
for (const file of files) {
|
||||
const filePath = path.join(categoryPath, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let description: string | undefined;
|
||||
const urls: string[] = [];
|
||||
let description: string | undefined;
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('# ') && !description) {
|
||||
description = trimmed.substring(2);
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('# ') && !description) {
|
||||
description = trimmed.substring(2);
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
playlists.push({
|
||||
category: category.name,
|
||||
name: file.replace('.txt', ''),
|
||||
path: filePath,
|
||||
urlCount: urls.length,
|
||||
urls,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
playlists.push({
|
||||
category: category.name,
|
||||
name: file.replace('.txt', ''),
|
||||
path: filePath,
|
||||
urlCount: urls.length,
|
||||
urls,
|
||||
description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
return playlists;
|
||||
}
|
||||
|
||||
async getOne(category: string, name: string): Promise<Playlist> {
|
||||
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
|
||||
async getOne(category: string, name: string): Promise<Playlist> {
|
||||
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Playlist ${category}/${name} not found`);
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Playlist ${category}/${name} not found`);
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
let description: string | undefined;
|
||||
const urls: string[] = [];
|
||||
let description: string | undefined;
|
||||
const urls: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('# ') && !description) {
|
||||
description = trimmed.substring(2);
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('# ') && !description) {
|
||||
description = trimmed.substring(2);
|
||||
} else if (trimmed && !trimmed.startsWith('#')) {
|
||||
urls.push(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
category,
|
||||
name,
|
||||
path: filePath,
|
||||
urlCount: urls.length,
|
||||
urls,
|
||||
description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category,
|
||||
name,
|
||||
path: filePath,
|
||||
urlCount: urls.length,
|
||||
urls,
|
||||
description,
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreatePlaylistDto): Promise<Playlist> {
|
||||
// Parse category/name format
|
||||
const parts = dto.name.split('/');
|
||||
const category = parts.length > 1 ? parts[0] : 'general';
|
||||
const name = parts.length > 1 ? parts[1] : dto.name;
|
||||
async create(dto: CreatePlaylistDto): Promise<Playlist> {
|
||||
// Parse category/name format
|
||||
const parts = dto.name.split('/');
|
||||
const category = parts.length > 1 ? parts[0] : 'general';
|
||||
const name = parts.length > 1 ? parts[1] : dto.name;
|
||||
|
||||
const categoryDir = path.join(this.playlistsDir, category);
|
||||
if (!fs.existsSync(categoryDir)) {
|
||||
fs.mkdirSync(categoryDir, { recursive: true });
|
||||
}
|
||||
const categoryDir = path.join(this.playlistsDir, category);
|
||||
if (!fs.existsSync(categoryDir)) {
|
||||
fs.mkdirSync(categoryDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(categoryDir, `${name}.txt`);
|
||||
const filePath = path.join(categoryDir, `${name}.txt`);
|
||||
|
||||
let content = '';
|
||||
if (dto.description) {
|
||||
content += `# ${dto.description}\n`;
|
||||
}
|
||||
content += '# One URL per line\n\n';
|
||||
content += dto.urls.join('\n') + '\n';
|
||||
let content = '';
|
||||
if (dto.description) {
|
||||
content += `# ${dto.description}\n`;
|
||||
}
|
||||
content += '# One URL per line\n\n';
|
||||
content += dto.urls.join('\n') + '\n';
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
|
||||
this.logger.log(`Created playlist: ${category}/${name}`);
|
||||
this.logger.log(`Created playlist: ${category}/${name}`);
|
||||
|
||||
return {
|
||||
category,
|
||||
name,
|
||||
path: filePath,
|
||||
urlCount: dto.urls.length,
|
||||
urls: dto.urls,
|
||||
description: dto.description,
|
||||
};
|
||||
}
|
||||
return {
|
||||
category,
|
||||
name,
|
||||
path: filePath,
|
||||
urlCount: dto.urls.length,
|
||||
urls: dto.urls,
|
||||
description: dto.description,
|
||||
};
|
||||
}
|
||||
|
||||
async delete(category: string, name: string): Promise<void> {
|
||||
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
|
||||
async delete(category: string, name: string): Promise<void> {
|
||||
const filePath = path.join(this.playlistsDir, category, `${name}.txt`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Playlist ${category}/${name} not found`);
|
||||
}
|
||||
if (!fs.existsSync(filePath)) {
|
||||
throw new NotFoundException(`Playlist ${category}/${name} not found`);
|
||||
}
|
||||
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.log(`Deleted playlist: ${category}/${name}`);
|
||||
}
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.log(`Deleted playlist: ${category}/${name}`);
|
||||
}
|
||||
|
||||
async addUrl(category: string, name: string, url: string): Promise<Playlist> {
|
||||
const playlist = await this.getOne(category, name);
|
||||
playlist.urls.push(url);
|
||||
async addUrl(category: string, name: string, url: string): Promise<Playlist> {
|
||||
const playlist = await this.getOne(category, name);
|
||||
playlist.urls.push(url);
|
||||
|
||||
const content =
|
||||
(playlist.description ? `# ${playlist.description}\n` : '') +
|
||||
'# One URL per line\n\n' +
|
||||
playlist.urls.join('\n') +
|
||||
'\n';
|
||||
const content =
|
||||
(playlist.description ? `# ${playlist.description}\n` : '') +
|
||||
'# One URL per line\n\n' +
|
||||
playlist.urls.join('\n') +
|
||||
'\n';
|
||||
|
||||
fs.writeFileSync(playlist.path, content, 'utf-8');
|
||||
fs.writeFileSync(playlist.path, content, 'utf-8');
|
||||
|
||||
playlist.urlCount = playlist.urls.length;
|
||||
return playlist;
|
||||
}
|
||||
playlist.urlCount = playlist.urls.length;
|
||||
return playlist;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
import { IsString, IsOptional, IsUrl, IsEnum } from 'class-validator';
|
||||
|
||||
export enum WhisperProviderEnum {
|
||||
GROQ = 'groq',
|
||||
LOCAL = 'local',
|
||||
GROQ = 'groq',
|
||||
LOCAL = 'local',
|
||||
}
|
||||
|
||||
export enum WhisperModelEnum {
|
||||
// Groq models (cloud)
|
||||
WHISPER_LARGE_V3_TURBO = 'whisper-large-v3-turbo',
|
||||
WHISPER_LARGE_V3 = 'whisper-large-v3',
|
||||
// Local models
|
||||
TINY = 'tiny',
|
||||
BASE = 'base',
|
||||
SMALL = 'small',
|
||||
MEDIUM = 'medium',
|
||||
LARGE = 'large',
|
||||
// Groq models (cloud)
|
||||
WHISPER_LARGE_V3_TURBO = 'whisper-large-v3-turbo',
|
||||
WHISPER_LARGE_V3 = 'whisper-large-v3',
|
||||
// Local models
|
||||
TINY = 'tiny',
|
||||
BASE = 'base',
|
||||
SMALL = 'small',
|
||||
MEDIUM = 'medium',
|
||||
LARGE = 'large',
|
||||
}
|
||||
|
||||
export class TranscribeRequestDto {
|
||||
@IsUrl()
|
||||
url: string;
|
||||
@IsUrl()
|
||||
url: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
language?: string = 'de';
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
language?: string = 'de';
|
||||
|
||||
@IsEnum(WhisperProviderEnum)
|
||||
@IsOptional()
|
||||
provider?: WhisperProviderEnum;
|
||||
@IsEnum(WhisperProviderEnum)
|
||||
@IsOptional()
|
||||
provider?: WhisperProviderEnum;
|
||||
|
||||
@IsEnum(WhisperModelEnum)
|
||||
@IsOptional()
|
||||
model?: WhisperModelEnum;
|
||||
@IsEnum(WhisperModelEnum)
|
||||
@IsOptional()
|
||||
model?: WhisperModelEnum;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,52 +1,46 @@
|
|||
export enum JobStatus {
|
||||
PENDING = 'pending',
|
||||
DOWNLOADING = 'downloading',
|
||||
TRANSCRIBING = 'transcribing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
PENDING = 'pending',
|
||||
DOWNLOADING = 'downloading',
|
||||
TRANSCRIBING = 'transcribing',
|
||||
COMPLETED = 'completed',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
thumbnail: string;
|
||||
uploadDate: string;
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
thumbnail: string;
|
||||
uploadDate: string;
|
||||
}
|
||||
|
||||
export class TranscriptionJob {
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
videoInfo?: VideoInfo;
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
createdAt: Date;
|
||||
completedAt?: Date;
|
||||
videoInfo?: VideoInfo;
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
url: string,
|
||||
language: string,
|
||||
provider: string,
|
||||
model?: string,
|
||||
) {
|
||||
this.id = id;
|
||||
this.url = url;
|
||||
this.language = language;
|
||||
this.provider = provider;
|
||||
this.model = model;
|
||||
this.status = JobStatus.PENDING;
|
||||
this.progress = 0;
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
constructor(id: string, url: string, language: string, provider: string, model?: string) {
|
||||
this.id = id;
|
||||
this.url = url;
|
||||
this.language = language;
|
||||
this.provider = provider;
|
||||
this.model = model;
|
||||
this.status = JobStatus.PENDING;
|
||||
this.progress = 0;
|
||||
this.createdAt = new Date();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,40 +1,33 @@
|
|||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Delete,
|
||||
Param,
|
||||
Body,
|
||||
} from '@nestjs/common';
|
||||
import { Controller, Get, Post, Delete, Param, Body } from '@nestjs/common';
|
||||
import { TranscriptionService } from './transcription.service';
|
||||
import { TranscribeRequestDto } from './dto/transcribe-request.dto';
|
||||
|
||||
@Controller('transcription')
|
||||
export class TranscriptionController {
|
||||
constructor(private readonly transcriptionService: TranscriptionService) {}
|
||||
constructor(private readonly transcriptionService: TranscriptionService) {}
|
||||
|
||||
@Post()
|
||||
async createJob(@Body() dto: TranscribeRequestDto) {
|
||||
return this.transcriptionService.createJob(dto);
|
||||
}
|
||||
@Post()
|
||||
async createJob(@Body() dto: TranscribeRequestDto) {
|
||||
return this.transcriptionService.createJob(dto);
|
||||
}
|
||||
|
||||
@Get()
|
||||
async getAllJobs() {
|
||||
return this.transcriptionService.getAllJobs();
|
||||
}
|
||||
@Get()
|
||||
async getAllJobs() {
|
||||
return this.transcriptionService.getAllJobs();
|
||||
}
|
||||
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
return this.transcriptionService.getStats();
|
||||
}
|
||||
@Get('stats')
|
||||
async getStats() {
|
||||
return this.transcriptionService.getStats();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
async getJob(@Param('id') id: string) {
|
||||
return this.transcriptionService.getJob(id);
|
||||
}
|
||||
@Get(':id')
|
||||
async getJob(@Param('id') id: string) {
|
||||
return this.transcriptionService.getJob(id);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async cancelJob(@Param('id') id: string) {
|
||||
return this.transcriptionService.cancelJob(id);
|
||||
}
|
||||
@Delete(':id')
|
||||
async cancelJob(@Param('id') id: string) {
|
||||
return this.transcriptionService.cancelJob(id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import { WhisperModule } from '../whisper/whisper.module';
|
|||
import { WebsocketModule } from '../websocket/websocket.module';
|
||||
|
||||
@Module({
|
||||
imports: [YoutubeModule, WhisperModule, WebsocketModule],
|
||||
controllers: [TranscriptionController],
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
imports: [YoutubeModule, WhisperModule, WebsocketModule],
|
||||
controllers: [TranscriptionController],
|
||||
providers: [TranscriptionService],
|
||||
exports: [TranscriptionService],
|
||||
})
|
||||
export class TranscriptionModule {}
|
||||
|
|
|
|||
|
|
@ -6,213 +6,200 @@ import * as path from 'path';
|
|||
import { YoutubeService } from '../youtube/youtube.service';
|
||||
import { WhisperService, WhisperProvider, WhisperModel } from '../whisper/whisper.service';
|
||||
import { ProgressGateway } from '../websocket/progress.gateway';
|
||||
import {
|
||||
TranscriptionJob,
|
||||
JobStatus,
|
||||
} from './entities/transcription-job.entity';
|
||||
import { TranscriptionJob, JobStatus } from './entities/transcription-job.entity';
|
||||
import { TranscribeRequestDto } from './dto/transcribe-request.dto';
|
||||
|
||||
@Injectable()
|
||||
export class TranscriptionService {
|
||||
private readonly logger = new Logger(TranscriptionService.name);
|
||||
private readonly jobs: Map<string, TranscriptionJob> = new Map();
|
||||
private readonly transcriptsDir: string;
|
||||
private readonly logger = new Logger(TranscriptionService.name);
|
||||
private readonly jobs: Map<string, TranscriptionJob> = new Map();
|
||||
private readonly transcriptsDir: string;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly youtubeService: YoutubeService,
|
||||
private readonly whisperService: WhisperService,
|
||||
private readonly progressGateway: ProgressGateway,
|
||||
) {
|
||||
this.transcriptsDir =
|
||||
this.configService.get<string>('TRANSCRIPTS_DIR') || './data/transcripts';
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly youtubeService: YoutubeService,
|
||||
private readonly whisperService: WhisperService,
|
||||
private readonly progressGateway: ProgressGateway
|
||||
) {
|
||||
this.transcriptsDir = this.configService.get<string>('TRANSCRIPTS_DIR') || './data/transcripts';
|
||||
|
||||
// Ensure transcripts directory exists
|
||||
if (!fs.existsSync(this.transcriptsDir)) {
|
||||
fs.mkdirSync(this.transcriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
// Ensure transcripts directory exists
|
||||
if (!fs.existsSync(this.transcriptsDir)) {
|
||||
fs.mkdirSync(this.transcriptsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
|
||||
const jobId = uuidv4();
|
||||
const job = new TranscriptionJob(
|
||||
jobId,
|
||||
dto.url,
|
||||
dto.language || 'de',
|
||||
dto.provider || 'openai',
|
||||
dto.model,
|
||||
);
|
||||
async createJob(dto: TranscribeRequestDto): Promise<TranscriptionJob> {
|
||||
const jobId = uuidv4();
|
||||
const job = new TranscriptionJob(
|
||||
jobId,
|
||||
dto.url,
|
||||
dto.language || 'de',
|
||||
dto.provider || 'openai',
|
||||
dto.model
|
||||
);
|
||||
|
||||
this.jobs.set(jobId, job);
|
||||
this.jobs.set(jobId, job);
|
||||
|
||||
// Start processing in background
|
||||
this.processJob(job);
|
||||
// Start processing in background
|
||||
this.processJob(job);
|
||||
|
||||
return job;
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(id: string): Promise<TranscriptionJob> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
throw new NotFoundException(`Job ${id} not found`);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
async getJob(id: string): Promise<TranscriptionJob> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
throw new NotFoundException(`Job ${id} not found`);
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
async getAllJobs(): Promise<TranscriptionJob[]> {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
async getAllJobs(): Promise<TranscriptionJob[]> {
|
||||
return Array.from(this.jobs.values());
|
||||
}
|
||||
|
||||
async cancelJob(id: string): Promise<TranscriptionJob> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
throw new NotFoundException(`Job ${id} not found`);
|
||||
}
|
||||
async cancelJob(id: string): Promise<TranscriptionJob> {
|
||||
const job = this.jobs.get(id);
|
||||
if (!job) {
|
||||
throw new NotFoundException(`Job ${id} not found`);
|
||||
}
|
||||
|
||||
if (
|
||||
job.status === JobStatus.PENDING ||
|
||||
job.status === JobStatus.DOWNLOADING ||
|
||||
job.status === JobStatus.TRANSCRIBING
|
||||
) {
|
||||
job.status = JobStatus.CANCELLED;
|
||||
job.error = 'Cancelled by user';
|
||||
if (
|
||||
job.status === JobStatus.PENDING ||
|
||||
job.status === JobStatus.DOWNLOADING ||
|
||||
job.status === JobStatus.TRANSCRIBING
|
||||
) {
|
||||
job.status = JobStatus.CANCELLED;
|
||||
job.error = 'Cancelled by user';
|
||||
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
error: job.error,
|
||||
});
|
||||
}
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
error: job.error,
|
||||
});
|
||||
}
|
||||
|
||||
return job;
|
||||
}
|
||||
return job;
|
||||
}
|
||||
|
||||
private async processJob(job: TranscriptionJob): Promise<void> {
|
||||
let audioPath: string | null = null;
|
||||
const jobId = job.id;
|
||||
private async processJob(job: TranscriptionJob): Promise<void> {
|
||||
let audioPath: string | null = null;
|
||||
const jobId = job.id;
|
||||
|
||||
// Helper to check if job was cancelled (re-reads from map to get current status)
|
||||
const isCancelled = (): boolean => {
|
||||
const currentJob = this.jobs.get(jobId);
|
||||
return currentJob?.status === JobStatus.CANCELLED;
|
||||
};
|
||||
// Helper to check if job was cancelled (re-reads from map to get current status)
|
||||
const isCancelled = (): boolean => {
|
||||
const currentJob = this.jobs.get(jobId);
|
||||
return currentJob?.status === JobStatus.CANCELLED;
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Get video info
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 5);
|
||||
try {
|
||||
// Step 1: Get video info
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 5);
|
||||
|
||||
const videoInfo = await this.youtubeService.getVideoInfo(job.url);
|
||||
job.videoInfo = videoInfo;
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 10);
|
||||
const videoInfo = await this.youtubeService.getVideoInfo(job.url);
|
||||
job.videoInfo = videoInfo;
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 10);
|
||||
|
||||
this.logger.log(`Processing: ${videoInfo.title}`);
|
||||
this.logger.log(`Processing: ${videoInfo.title}`);
|
||||
|
||||
// Check if cancelled
|
||||
if (isCancelled()) return;
|
||||
// Check if cancelled
|
||||
if (isCancelled()) return;
|
||||
|
||||
// Step 2: Download audio
|
||||
audioPath = await this.youtubeService.downloadAudio(job.url, (progress) => {
|
||||
const overallProgress = 10 + progress.percent * 0.4; // 10-50%
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, Math.round(overallProgress));
|
||||
});
|
||||
// Step 2: Download audio
|
||||
audioPath = await this.youtubeService.downloadAudio(job.url, (progress) => {
|
||||
const overallProgress = 10 + progress.percent * 0.4; // 10-50%
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, Math.round(overallProgress));
|
||||
});
|
||||
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 50);
|
||||
this.updateJobProgress(job, JobStatus.DOWNLOADING, 50);
|
||||
|
||||
// Check if cancelled
|
||||
if (isCancelled()) {
|
||||
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
|
||||
return;
|
||||
}
|
||||
// Check if cancelled
|
||||
if (isCancelled()) {
|
||||
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Transcribe
|
||||
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 55);
|
||||
// Step 3: Transcribe
|
||||
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 55);
|
||||
|
||||
const result = await this.whisperService.transcribe(
|
||||
audioPath,
|
||||
job.language,
|
||||
job.provider as WhisperProvider,
|
||||
job.model as WhisperModel,
|
||||
);
|
||||
const result = await this.whisperService.transcribe(
|
||||
audioPath,
|
||||
job.language,
|
||||
job.provider as WhisperProvider,
|
||||
job.model as WhisperModel
|
||||
);
|
||||
|
||||
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 90);
|
||||
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 90);
|
||||
|
||||
// Check if cancelled
|
||||
if (isCancelled()) {
|
||||
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
|
||||
return;
|
||||
}
|
||||
// Check if cancelled
|
||||
if (isCancelled()) {
|
||||
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 4: Save transcript
|
||||
const transcriptPath = await this.saveTranscript(
|
||||
job,
|
||||
videoInfo,
|
||||
result.text,
|
||||
);
|
||||
// Step 4: Save transcript
|
||||
const transcriptPath = await this.saveTranscript(job, videoInfo, result.text);
|
||||
|
||||
job.transcriptPath = transcriptPath;
|
||||
job.transcriptText = result.text;
|
||||
job.status = JobStatus.COMPLETED;
|
||||
job.progress = 100;
|
||||
job.completedAt = new Date();
|
||||
job.transcriptPath = transcriptPath;
|
||||
job.transcriptText = result.text;
|
||||
job.status = JobStatus.COMPLETED;
|
||||
job.progress = 100;
|
||||
job.completedAt = new Date();
|
||||
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
transcriptPath: job.transcriptPath,
|
||||
});
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
transcriptPath: job.transcriptPath,
|
||||
});
|
||||
|
||||
this.logger.log(`Completed: ${videoInfo.title}`);
|
||||
} catch (error) {
|
||||
job.status = JobStatus.FAILED;
|
||||
job.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
this.logger.log(`Completed: ${videoInfo.title}`);
|
||||
} catch (error) {
|
||||
job.status = JobStatus.FAILED;
|
||||
job.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
error: job.error,
|
||||
});
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
error: job.error,
|
||||
});
|
||||
|
||||
this.logger.error(`Job failed: ${job.error}`);
|
||||
} finally {
|
||||
// Cleanup audio file
|
||||
if (audioPath) {
|
||||
await this.youtubeService.cleanupFile(audioPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.logger.error(`Job failed: ${job.error}`);
|
||||
} finally {
|
||||
// Cleanup audio file
|
||||
if (audioPath) {
|
||||
await this.youtubeService.cleanupFile(audioPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateJobProgress(
|
||||
job: TranscriptionJob,
|
||||
status: JobStatus,
|
||||
progress: number,
|
||||
): void {
|
||||
job.status = status;
|
||||
job.progress = progress;
|
||||
private updateJobProgress(job: TranscriptionJob, status: JobStatus, progress: number): void {
|
||||
job.status = status;
|
||||
job.progress = progress;
|
||||
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
videoInfo: job.videoInfo,
|
||||
});
|
||||
}
|
||||
this.progressGateway.broadcastJobUpdate(job.id, {
|
||||
status: job.status,
|
||||
progress: job.progress,
|
||||
videoInfo: job.videoInfo,
|
||||
});
|
||||
}
|
||||
|
||||
private async saveTranscript(
|
||||
job: TranscriptionJob,
|
||||
videoInfo: { channel: string; title: string; id: string },
|
||||
text: string,
|
||||
): Promise<string> {
|
||||
// Sanitize names for filesystem
|
||||
const sanitize = (str: string) =>
|
||||
str.replace(/[^a-z0-9äöüß\-_]/gi, '_').substring(0, 50);
|
||||
private async saveTranscript(
|
||||
job: TranscriptionJob,
|
||||
videoInfo: { channel: string; title: string; id: string },
|
||||
text: string
|
||||
): Promise<string> {
|
||||
// Sanitize names for filesystem
|
||||
const sanitize = (str: string) => str.replace(/[^a-z0-9äöüß\-_]/gi, '_').substring(0, 50);
|
||||
|
||||
const channelDir = path.join(this.transcriptsDir, sanitize(videoInfo.channel));
|
||||
const channelDir = path.join(this.transcriptsDir, sanitize(videoInfo.channel));
|
||||
|
||||
if (!fs.existsSync(channelDir)) {
|
||||
fs.mkdirSync(channelDir, { recursive: true });
|
||||
}
|
||||
if (!fs.existsSync(channelDir)) {
|
||||
fs.mkdirSync(channelDir, { recursive: true });
|
||||
}
|
||||
|
||||
const filename = `${sanitize(videoInfo.title)}_${videoInfo.id}.txt`;
|
||||
const filePath = path.join(channelDir, filename);
|
||||
const filename = `${sanitize(videoInfo.title)}_${videoInfo.id}.txt`;
|
||||
const filePath = path.join(channelDir, filename);
|
||||
|
||||
const content = `# ${videoInfo.title}
|
||||
const content = `# ${videoInfo.title}
|
||||
Channel: ${videoInfo.channel}
|
||||
Video ID: ${videoInfo.id}
|
||||
Language: ${job.language}
|
||||
|
|
@ -224,44 +211,44 @@ Provider: ${job.provider}
|
|||
${text}
|
||||
`;
|
||||
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
fs.writeFileSync(filePath, content, 'utf-8');
|
||||
|
||||
return filePath;
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
async getStats() {
|
||||
const jobs = Array.from(this.jobs.values());
|
||||
async getStats() {
|
||||
const jobs = Array.from(this.jobs.values());
|
||||
|
||||
let totalTranscripts = 0;
|
||||
let totalSize = 0;
|
||||
let totalTranscripts = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
if (fs.existsSync(this.transcriptsDir)) {
|
||||
const countFiles = (dir: string) => {
|
||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (item.isDirectory()) {
|
||||
countFiles(fullPath);
|
||||
} else if (item.name.endsWith('.txt')) {
|
||||
totalTranscripts++;
|
||||
totalSize += fs.statSync(fullPath).size;
|
||||
}
|
||||
}
|
||||
};
|
||||
countFiles(this.transcriptsDir);
|
||||
}
|
||||
if (fs.existsSync(this.transcriptsDir)) {
|
||||
const countFiles = (dir: string) => {
|
||||
const items = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const item of items) {
|
||||
const fullPath = path.join(dir, item.name);
|
||||
if (item.isDirectory()) {
|
||||
countFiles(fullPath);
|
||||
} else if (item.name.endsWith('.txt')) {
|
||||
totalTranscripts++;
|
||||
totalSize += fs.statSync(fullPath).size;
|
||||
}
|
||||
}
|
||||
};
|
||||
countFiles(this.transcriptsDir);
|
||||
}
|
||||
|
||||
return {
|
||||
totalTranscripts,
|
||||
totalSizeMB: Math.round((totalSize / 1024 / 1024) * 100) / 100,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === JobStatus.PENDING ||
|
||||
j.status === JobStatus.DOWNLOADING ||
|
||||
j.status === JobStatus.TRANSCRIBING,
|
||||
).length,
|
||||
completedJobs: jobs.filter((j) => j.status === JobStatus.COMPLETED).length,
|
||||
failedJobs: jobs.filter((j) => j.status === JobStatus.FAILED).length,
|
||||
};
|
||||
}
|
||||
return {
|
||||
totalTranscripts,
|
||||
totalSizeMB: Math.round((totalSize / 1024 / 1024) * 100) / 100,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === JobStatus.PENDING ||
|
||||
j.status === JobStatus.DOWNLOADING ||
|
||||
j.status === JobStatus.TRANSCRIBING
|
||||
).length,
|
||||
completedJobs: jobs.filter((j) => j.status === JobStatus.COMPLETED).length,
|
||||
failedJobs: jobs.filter((j) => j.status === JobStatus.FAILED).length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,79 @@
|
|||
import {
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
OnGatewayConnection,
|
||||
OnGatewayDisconnect,
|
||||
} from '@nestjs/websockets';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
|
||||
export interface JobUpdatePayload {
|
||||
status: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
status: string;
|
||||
progress?: number;
|
||||
error?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
}
|
||||
|
||||
@WebSocketGateway({
|
||||
cors: {
|
||||
origin: [
|
||||
'http://localhost:5173',
|
||||
'http://localhost:4321',
|
||||
'http://localhost:3000',
|
||||
],
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/progress',
|
||||
cors: {
|
||||
origin: ['http://localhost:5173', 'http://localhost:4321', 'http://localhost:3000'],
|
||||
credentials: true,
|
||||
},
|
||||
namespace: '/progress',
|
||||
})
|
||||
export class ProgressGateway
|
||||
implements OnGatewayConnection, OnGatewayDisconnect
|
||||
{
|
||||
private readonly logger = new Logger(ProgressGateway.name);
|
||||
export class ProgressGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
private readonly logger = new Logger(ProgressGateway.name);
|
||||
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
handleConnection(client: Socket) {
|
||||
this.logger.log(`Client connected: ${client.id}`);
|
||||
|
||||
// Send heartbeat every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
client.emit('heartbeat', { timestamp: Date.now() });
|
||||
}, 10000);
|
||||
// Send heartbeat every 10 seconds
|
||||
const interval = setInterval(() => {
|
||||
client.emit('heartbeat', { timestamp: Date.now() });
|
||||
}, 10000);
|
||||
|
||||
client.on('disconnect', () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
}
|
||||
client.on('disconnect', () => {
|
||||
clearInterval(interval);
|
||||
});
|
||||
}
|
||||
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
handleDisconnect(client: Socket) {
|
||||
this.logger.log(`Client disconnected: ${client.id}`);
|
||||
}
|
||||
|
||||
broadcastJobUpdate(jobId: string, payload: JobUpdatePayload) {
|
||||
this.server.emit('job_update', {
|
||||
type: 'job_update',
|
||||
jobId,
|
||||
...payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
broadcastJobUpdate(jobId: string, payload: JobUpdatePayload) {
|
||||
this.server.emit('job_update', {
|
||||
type: 'job_update',
|
||||
jobId,
|
||||
...payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
broadcastJobComplete(jobId: string, payload: JobUpdatePayload) {
|
||||
this.server.emit('job_complete', {
|
||||
type: 'job_complete',
|
||||
jobId,
|
||||
...payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
broadcastJobComplete(jobId: string, payload: JobUpdatePayload) {
|
||||
this.server.emit('job_complete', {
|
||||
type: 'job_complete',
|
||||
jobId,
|
||||
...payload,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
broadcastJobError(jobId: string, error: string) {
|
||||
this.server.emit('job_error', {
|
||||
type: 'job_error',
|
||||
jobId,
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
broadcastJobError(jobId: string, error: string) {
|
||||
this.server.emit('job_error', {
|
||||
type: 'job_error',
|
||||
jobId,
|
||||
error,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { ProgressGateway } from './progress.gateway';
|
|||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ProgressGateway],
|
||||
exports: [ProgressGateway],
|
||||
providers: [ProgressGateway],
|
||||
exports: [ProgressGateway],
|
||||
})
|
||||
export class WebsocketModule {}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import { WhisperService } from './whisper.service';
|
|||
|
||||
@Controller('whisper')
|
||||
export class WhisperController {
|
||||
constructor(private readonly whisperService: WhisperService) {}
|
||||
constructor(private readonly whisperService: WhisperService) {}
|
||||
|
||||
@Get('models')
|
||||
getModels() {
|
||||
return {
|
||||
models: this.whisperService.getAvailableModels(),
|
||||
defaultProvider: this.whisperService.getDefaultProvider(),
|
||||
defaultModel: this.whisperService.getDefaultModel(),
|
||||
groqAvailable: this.whisperService.isGroqAvailable(),
|
||||
};
|
||||
}
|
||||
@Get('models')
|
||||
getModels() {
|
||||
return {
|
||||
models: this.whisperService.getAvailableModels(),
|
||||
defaultProvider: this.whisperService.getDefaultProvider(),
|
||||
defaultModel: this.whisperService.getDefaultModel(),
|
||||
groqAvailable: this.whisperService.isGroqAvailable(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { WhisperService } from './whisper.service';
|
|||
import { WhisperController } from './whisper.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [WhisperController],
|
||||
providers: [WhisperService],
|
||||
exports: [WhisperService],
|
||||
controllers: [WhisperController],
|
||||
providers: [WhisperService],
|
||||
exports: [WhisperService],
|
||||
})
|
||||
export class WhisperModule {}
|
||||
|
|
|
|||
|
|
@ -10,134 +10,118 @@ export type LocalWhisperModel = 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
|||
export type WhisperModel = GroqWhisperModel | LocalWhisperModel;
|
||||
|
||||
export interface TranscriptionResult {
|
||||
text: string;
|
||||
language: string;
|
||||
duration: number;
|
||||
provider: WhisperProvider;
|
||||
text: string;
|
||||
language: string;
|
||||
duration: number;
|
||||
provider: WhisperProvider;
|
||||
}
|
||||
|
||||
export interface WhisperModelInfo {
|
||||
name: string;
|
||||
provider: WhisperProvider;
|
||||
speed: string;
|
||||
accuracy: string;
|
||||
cost?: string;
|
||||
name: string;
|
||||
provider: WhisperProvider;
|
||||
speed: string;
|
||||
accuracy: string;
|
||||
cost?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WhisperService {
|
||||
private readonly logger = new Logger(WhisperService.name);
|
||||
private readonly groqClient: OpenAI | null;
|
||||
private readonly defaultProvider: WhisperProvider;
|
||||
private readonly defaultModel: WhisperModel;
|
||||
private readonly logger = new Logger(WhisperService.name);
|
||||
private readonly groqClient: OpenAI | null;
|
||||
private readonly defaultProvider: WhisperProvider;
|
||||
private readonly defaultModel: WhisperModel;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const groqApiKey = this.configService.get<string>('GROQ_API_KEY');
|
||||
constructor(private configService: ConfigService) {
|
||||
const groqApiKey = this.configService.get<string>('GROQ_API_KEY');
|
||||
|
||||
if (groqApiKey) {
|
||||
// Groq uses OpenAI-compatible API
|
||||
this.groqClient = new OpenAI({
|
||||
apiKey: groqApiKey,
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
});
|
||||
this.logger.log('Groq API configured successfully');
|
||||
} else {
|
||||
this.groqClient = null;
|
||||
this.logger.warn(
|
||||
'Groq API key not configured. Only local Whisper available.',
|
||||
);
|
||||
}
|
||||
if (groqApiKey) {
|
||||
// Groq uses OpenAI-compatible API
|
||||
this.groqClient = new OpenAI({
|
||||
apiKey: groqApiKey,
|
||||
baseURL: 'https://api.groq.com/openai/v1',
|
||||
});
|
||||
this.logger.log('Groq API configured successfully');
|
||||
} else {
|
||||
this.groqClient = null;
|
||||
this.logger.warn('Groq API key not configured. Only local Whisper available.');
|
||||
}
|
||||
|
||||
this.defaultProvider =
|
||||
(this.configService.get<string>('WHISPER_PROVIDER') as WhisperProvider) ||
|
||||
'groq';
|
||||
this.defaultModel =
|
||||
(this.configService.get<string>('WHISPER_MODEL') as WhisperModel) ||
|
||||
'whisper-large-v3-turbo';
|
||||
}
|
||||
this.defaultProvider =
|
||||
(this.configService.get<string>('WHISPER_PROVIDER') as WhisperProvider) || 'groq';
|
||||
this.defaultModel =
|
||||
(this.configService.get<string>('WHISPER_MODEL') as WhisperModel) || 'whisper-large-v3-turbo';
|
||||
}
|
||||
|
||||
async transcribe(
|
||||
audioPath: string,
|
||||
language: string = 'de',
|
||||
provider?: WhisperProvider,
|
||||
model?: WhisperModel,
|
||||
): Promise<TranscriptionResult> {
|
||||
const selectedProvider = provider || this.defaultProvider;
|
||||
const selectedModel = model || this.defaultModel;
|
||||
async transcribe(
|
||||
audioPath: string,
|
||||
language: string = 'de',
|
||||
provider?: WhisperProvider,
|
||||
model?: WhisperModel
|
||||
): Promise<TranscriptionResult> {
|
||||
const selectedProvider = provider || this.defaultProvider;
|
||||
const selectedModel = model || this.defaultModel;
|
||||
|
||||
// Fallback to local if Groq not available
|
||||
if (selectedProvider === 'groq' && !this.groqClient) {
|
||||
this.logger.warn('Groq not configured, falling back to local Whisper');
|
||||
return this.transcribeWithLocalWhisper(
|
||||
audioPath,
|
||||
language,
|
||||
selectedModel as LocalWhisperModel,
|
||||
);
|
||||
}
|
||||
// Fallback to local if Groq not available
|
||||
if (selectedProvider === 'groq' && !this.groqClient) {
|
||||
this.logger.warn('Groq not configured, falling back to local Whisper');
|
||||
return this.transcribeWithLocalWhisper(
|
||||
audioPath,
|
||||
language,
|
||||
selectedModel as LocalWhisperModel
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedProvider === 'groq') {
|
||||
return this.transcribeWithGroq(
|
||||
audioPath,
|
||||
language,
|
||||
selectedModel as GroqWhisperModel,
|
||||
);
|
||||
}
|
||||
if (selectedProvider === 'groq') {
|
||||
return this.transcribeWithGroq(audioPath, language, selectedModel as GroqWhisperModel);
|
||||
}
|
||||
|
||||
return this.transcribeWithLocalWhisper(
|
||||
audioPath,
|
||||
language,
|
||||
selectedModel as LocalWhisperModel,
|
||||
);
|
||||
}
|
||||
return this.transcribeWithLocalWhisper(audioPath, language, selectedModel as LocalWhisperModel);
|
||||
}
|
||||
|
||||
private async transcribeWithGroq(
|
||||
audioPath: string,
|
||||
language: string,
|
||||
model: GroqWhisperModel = 'whisper-large-v3-turbo',
|
||||
): Promise<TranscriptionResult> {
|
||||
if (!this.groqClient) {
|
||||
throw new Error('Groq API not configured');
|
||||
}
|
||||
private async transcribeWithGroq(
|
||||
audioPath: string,
|
||||
language: string,
|
||||
model: GroqWhisperModel = 'whisper-large-v3-turbo'
|
||||
): Promise<TranscriptionResult> {
|
||||
if (!this.groqClient) {
|
||||
throw new Error('Groq API not configured');
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Transcribing with Groq Whisper API (${model}): ${audioPath}`,
|
||||
);
|
||||
this.logger.log(`Transcribing with Groq Whisper API (${model}): ${audioPath}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
const transcription = await this.groqClient.audio.transcriptions.create({
|
||||
file: fs.createReadStream(audioPath),
|
||||
model: model,
|
||||
language,
|
||||
response_format: 'verbose_json',
|
||||
});
|
||||
const transcription = await this.groqClient.audio.transcriptions.create({
|
||||
file: fs.createReadStream(audioPath),
|
||||
model: model,
|
||||
language,
|
||||
response_format: 'verbose_json',
|
||||
});
|
||||
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
this.logger.log(`Groq transcription completed in ${duration.toFixed(2)}s`);
|
||||
this.logger.log(`Groq transcription completed in ${duration.toFixed(2)}s`);
|
||||
|
||||
return {
|
||||
text: transcription.text,
|
||||
language: transcription.language || language,
|
||||
duration,
|
||||
provider: 'groq',
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: transcription.text,
|
||||
language: transcription.language || language,
|
||||
duration,
|
||||
provider: 'groq',
|
||||
};
|
||||
}
|
||||
|
||||
private async transcribeWithLocalWhisper(
|
||||
audioPath: string,
|
||||
language: string,
|
||||
model: WhisperModel,
|
||||
): Promise<TranscriptionResult> {
|
||||
this.logger.log(
|
||||
`Transcribing with local Whisper (model: ${model}): ${audioPath}`,
|
||||
);
|
||||
private async transcribeWithLocalWhisper(
|
||||
audioPath: string,
|
||||
language: string,
|
||||
model: WhisperModel
|
||||
): Promise<TranscriptionResult> {
|
||||
this.logger.log(`Transcribing with local Whisper (model: ${model}): ${audioPath}`);
|
||||
|
||||
const startTime = Date.now();
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Python script to run Whisper
|
||||
const pythonScript = `
|
||||
return new Promise((resolve, reject) => {
|
||||
// Python script to run Whisper
|
||||
const pythonScript = `
|
||||
import whisper
|
||||
import json
|
||||
import sys
|
||||
|
|
@ -147,89 +131,89 @@ result = model.transcribe("${audioPath}", language="${language}")
|
|||
print(json.dumps({"text": result["text"], "language": result.get("language", "${language}")}))
|
||||
`.trim();
|
||||
|
||||
const python = spawn('python3', ['-c', pythonScript]);
|
||||
const python = spawn('python3', ['-c', pythonScript]);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
python.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
python.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
python.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
// Whisper outputs progress to stderr, log it
|
||||
this.logger.debug(data.toString());
|
||||
});
|
||||
python.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
// Whisper outputs progress to stderr, log it
|
||||
this.logger.debug(data.toString());
|
||||
});
|
||||
|
||||
python.on('close', (code) => {
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
python.on('close', (code) => {
|
||||
const duration = (Date.now() - startTime) / 1000;
|
||||
|
||||
if (code !== 0) {
|
||||
this.logger.error(`Local Whisper error: ${stderr}`);
|
||||
reject(new Error(`Transcription failed: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
if (code !== 0) {
|
||||
this.logger.error(`Local Whisper error: ${stderr}`);
|
||||
reject(new Error(`Transcription failed: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = JSON.parse(stdout.trim());
|
||||
resolve({
|
||||
text: result.text,
|
||||
language: result.language,
|
||||
duration,
|
||||
provider: 'local',
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse transcription result'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = JSON.parse(stdout.trim());
|
||||
resolve({
|
||||
text: result.text,
|
||||
language: result.language,
|
||||
duration,
|
||||
provider: 'local',
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse transcription result'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
getAvailableModels(): WhisperModelInfo[] {
|
||||
const models: WhisperModelInfo[] = [];
|
||||
getAvailableModels(): WhisperModelInfo[] {
|
||||
const models: WhisperModelInfo[] = [];
|
||||
|
||||
// Groq models (cloud, ultra-fast)
|
||||
if (this.groqClient) {
|
||||
models.push(
|
||||
{
|
||||
name: 'whisper-large-v3-turbo',
|
||||
provider: 'groq',
|
||||
speed: '~300x realtime',
|
||||
accuracy: '95%',
|
||||
cost: '$0.04/hour',
|
||||
},
|
||||
{
|
||||
name: 'whisper-large-v3',
|
||||
provider: 'groq',
|
||||
speed: '~250x realtime',
|
||||
accuracy: '97%',
|
||||
cost: '$0.111/hour',
|
||||
},
|
||||
);
|
||||
}
|
||||
// Groq models (cloud, ultra-fast)
|
||||
if (this.groqClient) {
|
||||
models.push(
|
||||
{
|
||||
name: 'whisper-large-v3-turbo',
|
||||
provider: 'groq',
|
||||
speed: '~300x realtime',
|
||||
accuracy: '95%',
|
||||
cost: '$0.04/hour',
|
||||
},
|
||||
{
|
||||
name: 'whisper-large-v3',
|
||||
provider: 'groq',
|
||||
speed: '~250x realtime',
|
||||
accuracy: '97%',
|
||||
cost: '$0.111/hour',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Local models
|
||||
models.push(
|
||||
{ name: 'tiny', provider: 'local', speed: '~10x realtime', accuracy: '75%' },
|
||||
{ name: 'base', provider: 'local', speed: '~7x realtime', accuracy: '85%' },
|
||||
{ name: 'small', provider: 'local', speed: '~4x realtime', accuracy: '91%' },
|
||||
{ name: 'medium', provider: 'local', speed: '~2x realtime', accuracy: '94%' },
|
||||
{ name: 'large', provider: 'local', speed: '~1x realtime', accuracy: '96-98%' },
|
||||
);
|
||||
// Local models
|
||||
models.push(
|
||||
{ name: 'tiny', provider: 'local', speed: '~10x realtime', accuracy: '75%' },
|
||||
{ name: 'base', provider: 'local', speed: '~7x realtime', accuracy: '85%' },
|
||||
{ name: 'small', provider: 'local', speed: '~4x realtime', accuracy: '91%' },
|
||||
{ name: 'medium', provider: 'local', speed: '~2x realtime', accuracy: '94%' },
|
||||
{ name: 'large', provider: 'local', speed: '~1x realtime', accuracy: '96-98%' }
|
||||
);
|
||||
|
||||
return models;
|
||||
}
|
||||
return models;
|
||||
}
|
||||
|
||||
isGroqAvailable(): boolean {
|
||||
return this.groqClient !== null;
|
||||
}
|
||||
isGroqAvailable(): boolean {
|
||||
return this.groqClient !== null;
|
||||
}
|
||||
|
||||
getDefaultProvider(): WhisperProvider {
|
||||
return this.defaultProvider;
|
||||
}
|
||||
getDefaultProvider(): WhisperProvider {
|
||||
return this.defaultProvider;
|
||||
}
|
||||
|
||||
getDefaultModel(): WhisperModel {
|
||||
return this.defaultModel;
|
||||
}
|
||||
getDefaultModel(): WhisperModel {
|
||||
return this.defaultModel;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Module } from '@nestjs/common';
|
|||
import { YoutubeService } from './youtube.service';
|
||||
|
||||
@Module({
|
||||
providers: [YoutubeService],
|
||||
exports: [YoutubeService],
|
||||
providers: [YoutubeService],
|
||||
exports: [YoutubeService],
|
||||
})
|
||||
export class YoutubeModule {}
|
||||
|
|
|
|||
|
|
@ -6,161 +6,158 @@ import * as fs from 'fs';
|
|||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
thumbnail: string;
|
||||
uploadDate: string;
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
duration: number;
|
||||
channel: string;
|
||||
channelId: string;
|
||||
thumbnail: string;
|
||||
uploadDate: string;
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
percent: number;
|
||||
speed: string;
|
||||
eta: string;
|
||||
percent: number;
|
||||
speed: string;
|
||||
eta: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class YoutubeService {
|
||||
private readonly logger = new Logger(YoutubeService.name);
|
||||
private readonly tempDir: string;
|
||||
private readonly logger = new Logger(YoutubeService.name);
|
||||
private readonly tempDir: string;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
this.tempDir =
|
||||
this.configService.get<string>('TEMP_AUDIO_DIR') || './temp_audio';
|
||||
constructor(private configService: ConfigService) {
|
||||
this.tempDir = this.configService.get<string>('TEMP_AUDIO_DIR') || './temp_audio';
|
||||
|
||||
// Ensure temp directory exists
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
// Ensure temp directory exists
|
||||
if (!fs.existsSync(this.tempDir)) {
|
||||
fs.mkdirSync(this.tempDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytdlp = spawn('yt-dlp', ['--dump-json', '--no-download', url]);
|
||||
async getVideoInfo(url: string): Promise<VideoInfo> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytdlp = spawn('yt-dlp', ['--dump-json', '--no-download', url]);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
ytdlp.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
ytdlp.stdout.on('data', (data) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
ytdlp.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
ytdlp.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ytdlp.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`yt-dlp info error: ${stderr}`);
|
||||
reject(new Error(`Failed to get video info: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
ytdlp.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`yt-dlp info error: ${stderr}`);
|
||||
reject(new Error(`Failed to get video info: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const info = JSON.parse(stdout);
|
||||
resolve({
|
||||
id: info.id,
|
||||
title: info.title,
|
||||
description: info.description || '',
|
||||
duration: info.duration,
|
||||
channel: info.channel || info.uploader,
|
||||
channelId: info.channel_id || info.uploader_id,
|
||||
thumbnail: info.thumbnail,
|
||||
uploadDate: info.upload_date,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse video info'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
try {
|
||||
const info = JSON.parse(stdout);
|
||||
resolve({
|
||||
id: info.id,
|
||||
title: info.title,
|
||||
description: info.description || '',
|
||||
duration: info.duration,
|
||||
channel: info.channel || info.uploader,
|
||||
channelId: info.channel_id || info.uploader_id,
|
||||
thumbnail: info.thumbnail,
|
||||
uploadDate: info.upload_date,
|
||||
});
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to parse video info'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async downloadAudio(
|
||||
url: string,
|
||||
onProgress?: (progress: DownloadProgress) => void,
|
||||
): Promise<string> {
|
||||
const outputId = uuidv4();
|
||||
const outputPath = path.join(this.tempDir, `${outputId}.mp3`);
|
||||
async downloadAudio(
|
||||
url: string,
|
||||
onProgress?: (progress: DownloadProgress) => void
|
||||
): Promise<string> {
|
||||
const outputId = uuidv4();
|
||||
const outputPath = path.join(this.tempDir, `${outputId}.mp3`);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytdlp = spawn('yt-dlp', [
|
||||
'-x',
|
||||
'--audio-format',
|
||||
'mp3',
|
||||
'--audio-quality',
|
||||
'0',
|
||||
'-o',
|
||||
outputPath.replace('.mp3', '.%(ext)s'),
|
||||
'--newline',
|
||||
url,
|
||||
]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const ytdlp = spawn('yt-dlp', [
|
||||
'-x',
|
||||
'--audio-format',
|
||||
'mp3',
|
||||
'--audio-quality',
|
||||
'0',
|
||||
'-o',
|
||||
outputPath.replace('.mp3', '.%(ext)s'),
|
||||
'--newline',
|
||||
url,
|
||||
]);
|
||||
|
||||
let stderr = '';
|
||||
let stderr = '';
|
||||
|
||||
ytdlp.stdout.on('data', (data) => {
|
||||
const line = data.toString();
|
||||
ytdlp.stdout.on('data', (data) => {
|
||||
const line = data.toString();
|
||||
|
||||
// Parse download progress
|
||||
const progressMatch = line.match(
|
||||
/(\d+\.?\d*)%.*?(\d+\.?\d*\w+\/s).*?ETA\s+(\d+:\d+)/,
|
||||
);
|
||||
if (progressMatch && onProgress) {
|
||||
onProgress({
|
||||
percent: parseFloat(progressMatch[1]),
|
||||
speed: progressMatch[2],
|
||||
eta: progressMatch[3],
|
||||
});
|
||||
}
|
||||
});
|
||||
// Parse download progress
|
||||
const progressMatch = line.match(/(\d+\.?\d*)%.*?(\d+\.?\d*\w+\/s).*?ETA\s+(\d+:\d+)/);
|
||||
if (progressMatch && onProgress) {
|
||||
onProgress({
|
||||
percent: parseFloat(progressMatch[1]),
|
||||
speed: progressMatch[2],
|
||||
eta: progressMatch[3],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ytdlp.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
ytdlp.stderr.on('data', (data) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
ytdlp.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`yt-dlp download error: ${stderr}`);
|
||||
reject(new Error(`Download failed: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
ytdlp.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
this.logger.error(`yt-dlp download error: ${stderr}`);
|
||||
reject(new Error(`Download failed: ${stderr}`));
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the actual output file (might have different extension initially)
|
||||
const files = fs.readdirSync(this.tempDir);
|
||||
const outputFile = files.find((f) => f.startsWith(outputId));
|
||||
// Find the actual output file (might have different extension initially)
|
||||
const files = fs.readdirSync(this.tempDir);
|
||||
const outputFile = files.find((f) => f.startsWith(outputId));
|
||||
|
||||
if (!outputFile) {
|
||||
reject(new Error('Output file not found'));
|
||||
return;
|
||||
}
|
||||
if (!outputFile) {
|
||||
reject(new Error('Output file not found'));
|
||||
return;
|
||||
}
|
||||
|
||||
const actualPath = path.join(this.tempDir, outputFile);
|
||||
this.logger.log(`Downloaded audio to: ${actualPath}`);
|
||||
resolve(actualPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
const actualPath = path.join(this.tempDir, outputFile);
|
||||
this.logger.log(`Downloaded audio to: ${actualPath}`);
|
||||
resolve(actualPath);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async cleanupFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.log(`Cleaned up: ${filePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to cleanup file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
async cleanupFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
this.logger.log(`Cleaned up: ${filePath}`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.warn(`Failed to cleanup file: ${filePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
isValidYoutubeUrl(url: string): boolean {
|
||||
const patterns = [
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//,
|
||||
/^(https?:\/\/)?(www\.)?youtube\.com\/watch\?v=/,
|
||||
/^(https?:\/\/)?youtu\.be\//,
|
||||
];
|
||||
isValidYoutubeUrl(url: string): boolean {
|
||||
const patterns = [
|
||||
/^(https?:\/\/)?(www\.)?(youtube\.com|youtu\.be)\//,
|
||||
/^(https?:\/\/)?(www\.)?youtube\.com\/watch\?v=/,
|
||||
/^(https?:\/\/)?youtu\.be\//,
|
||||
];
|
||||
|
||||
return patterns.some((pattern) => pattern.test(url));
|
||||
}
|
||||
return patterns.some((pattern) => pattern.test(url));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,27 +1,27 @@
|
|||
{
|
||||
"name": "@wisekeep/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"lint": "eslint .",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/solid-js": "^4.4.0",
|
||||
"astro": "^4.16.0",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@wisekeep/landing",
|
||||
"type": "module",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"lint": "eslint .",
|
||||
"type-check": "astro check"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/solid-js": "^4.4.0",
|
||||
"astro": "^4.16.0",
|
||||
"solid-js": "^1.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/check": "^0.9.0",
|
||||
"@astrojs/tailwind": "^5.1.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,162 +1,172 @@
|
|||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
defaultCollapsed?: boolean;
|
||||
className?: string;
|
||||
title: string;
|
||||
icon?: string;
|
||||
defaultCollapsed?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const { title, icon = '📌', defaultCollapsed = false, className = '' } = Astro.props;
|
||||
const sectionId = title.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9-]/g, '');
|
||||
const sectionId = title
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '');
|
||||
---
|
||||
|
||||
<div class={`collapsible-section ${className}`} data-section-id={sectionId}>
|
||||
<button
|
||||
class="section-header"
|
||||
aria-expanded={!defaultCollapsed}
|
||||
aria-controls={`content-${sectionId}`}
|
||||
>
|
||||
<span class="section-icon">{icon}</span>
|
||||
<h2 class="section-title">{title}</h2>
|
||||
<span class="section-arrow" data-collapsed={defaultCollapsed}>
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id={`content-${sectionId}`}
|
||||
class="section-content"
|
||||
data-collapsed={defaultCollapsed}
|
||||
>
|
||||
<div class="section-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="section-header"
|
||||
aria-expanded={!defaultCollapsed}
|
||||
aria-controls={`content-${sectionId}`}
|
||||
>
|
||||
<span class="section-icon">{icon}</span>
|
||||
<h2 class="section-title">{title}</h2>
|
||||
<span class="section-arrow" data-collapsed={defaultCollapsed}>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 9L12 15L18 9"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
<div id={`content-${sectionId}`} class="section-content" data-collapsed={defaultCollapsed}>
|
||||
<div class="section-inner">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.collapsible-section {
|
||||
background: rgb(var(--theme-card));
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
.collapsible-section {
|
||||
background: rgb(var(--theme-card));
|
||||
border-radius: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
.collapsible-section:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.collapsible-section:hover {
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
.section-header {
|
||||
width: 100%;
|
||||
padding: 1.5rem 2rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: left;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.section-header:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
}
|
||||
.section-header:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
}
|
||||
|
||||
.section-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.section-icon {
|
||||
font-size: 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
.section-title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.section-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
.section-arrow {
|
||||
flex-shrink: 0;
|
||||
transition: transform 0.3s ease;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.section-arrow[data-collapsed="true"] {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.section-arrow[data-collapsed='true'] {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.section-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease-in-out;
|
||||
}
|
||||
.section-content {
|
||||
max-height: 2000px;
|
||||
overflow: hidden;
|
||||
transition: max-height 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.section-content[data-collapsed="true"] {
|
||||
max-height: 0;
|
||||
}
|
||||
.section-content[data-collapsed='true'] {
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
.section-inner {
|
||||
padding: 0 2rem 2rem 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.section-header {
|
||||
padding: 1.25rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-inner {
|
||||
padding: 0 1.5rem 1.5rem 1.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sections = document.querySelectorAll('.collapsible-section');
|
||||
|
||||
sections.forEach(section => {
|
||||
const header = section.querySelector('.section-header');
|
||||
const content = section.querySelector('.section-content');
|
||||
const arrow = section.querySelector('.section-arrow');
|
||||
|
||||
if (!header || !content || !arrow) return;
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isCollapsed = content.dataset.collapsed === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
content.dataset.collapsed = 'false';
|
||||
arrow.dataset.collapsed = 'false';
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Calculate actual height for smooth animation
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
} else {
|
||||
content.dataset.collapsed = 'true';
|
||||
arrow.dataset.collapsed = 'true';
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
content.style.maxHeight = '0';
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial max-height for expanded sections
|
||||
if (content.dataset.collapsed === 'false') {
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const sections = document.querySelectorAll('.collapsible-section');
|
||||
|
||||
sections.forEach((section) => {
|
||||
const header = section.querySelector('.section-header');
|
||||
const content = section.querySelector('.section-content');
|
||||
const arrow = section.querySelector('.section-arrow');
|
||||
|
||||
if (!header || !content || !arrow) return;
|
||||
|
||||
header.addEventListener('click', () => {
|
||||
const isCollapsed = content.dataset.collapsed === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
content.dataset.collapsed = 'false';
|
||||
arrow.dataset.collapsed = 'false';
|
||||
header.setAttribute('aria-expanded', 'true');
|
||||
|
||||
// Calculate actual height for smooth animation
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
} else {
|
||||
content.dataset.collapsed = 'true';
|
||||
arrow.dataset.collapsed = 'true';
|
||||
header.setAttribute('aria-expanded', 'false');
|
||||
content.style.maxHeight = '0';
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial max-height for expanded sections
|
||||
if (content.dataset.collapsed === 'false') {
|
||||
const inner = content.querySelector('.section-inner');
|
||||
if (inner) {
|
||||
content.style.maxHeight = inner.scrollHeight + 'px';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,103 +1,104 @@
|
|||
import { Component } from 'solid-js';
|
||||
|
||||
interface ContentCardProps {
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const ContentCard: Component<ContentCardProps> = (props) => {
|
||||
return (
|
||||
<a href={props.link} class="group relative flex flex-col h-full cursor-pointer">
|
||||
{/* Card Container with hover effects */}
|
||||
<article class="glass rounded-2xl overflow-hidden h-full flex flex-col transition-all duration-500 hover:shadow-theme-xl hover:-translate-y-1 border border-theme-border/50 hover:border-theme-primary/30">
|
||||
|
||||
{/* Gradient overlay on hover */}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-theme-primary/5 to-theme-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none rounded-2xl"></div>
|
||||
|
||||
|
||||
{/* Content section */}
|
||||
<div class="flex-1 p-6 flex flex-col relative z-10">
|
||||
{/* Title */}
|
||||
<h3 class="text-xl font-bold mb-3 text-theme-text group-hover:text-theme-primary transition-colors duration-300 line-clamp-2">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
{/* Meta information */}
|
||||
<div class="flex items-center gap-3 text-sm text-theme-text-muted mb-3">
|
||||
{props.speakerId ? (
|
||||
<a
|
||||
href={`/speakers/${props.speakerId}`}
|
||||
class="flex items-center gap-1 hover:text-theme-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</span>
|
||||
)}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>⏱️ {props.duration}</span>
|
||||
{props.date && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>{props.date}</span>
|
||||
</>
|
||||
)}
|
||||
{props.views && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>👁️ {props.views}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p class="text-theme-text-muted mb-4 line-clamp-3 flex-1">
|
||||
{props.excerpt}
|
||||
</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{props.tags.map(tag => (
|
||||
<span class="px-3 py-1 bg-theme-surface rounded-full text-xs font-medium text-theme-text-muted border border-theme-border/50">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Text (no longer a link since whole card is clickable) */}
|
||||
<div class="inline-flex items-center gap-2 text-theme-primary font-semibold transition-all duration-300">
|
||||
<span>Weiterlesen</span>
|
||||
<svg
|
||||
class="w-4 h-4 transform group-hover:translate-x-1 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5m0 0l-5 5m5-5H6"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative corner accent */}
|
||||
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-theme-primary/10 to-transparent rounded-bl-[40px] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</article>
|
||||
</a>
|
||||
);
|
||||
return (
|
||||
<a href={props.link} class="group relative flex flex-col h-full cursor-pointer">
|
||||
{/* Card Container with hover effects */}
|
||||
<article class="glass rounded-2xl overflow-hidden h-full flex flex-col transition-all duration-500 hover:shadow-theme-xl hover:-translate-y-1 border border-theme-border/50 hover:border-theme-primary/30">
|
||||
{/* Gradient overlay on hover */}
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-theme-primary/5 to-theme-accent/5 opacity-0 group-hover:opacity-100 transition-opacity duration-500 pointer-events-none rounded-2xl"></div>
|
||||
|
||||
{/* Content section */}
|
||||
<div class="flex-1 p-6 flex flex-col relative z-10">
|
||||
{/* Title */}
|
||||
<h3 class="text-xl font-bold mb-3 text-theme-text group-hover:text-theme-primary transition-colors duration-300 line-clamp-2">
|
||||
{props.title}
|
||||
</h3>
|
||||
|
||||
{/* Meta information */}
|
||||
<div class="flex items-center gap-3 text-sm text-theme-text-muted mb-3">
|
||||
{props.speakerId ? (
|
||||
<a
|
||||
href={`/speakers/${props.speakerId}`}
|
||||
class="flex items-center gap-1 hover:text-theme-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</a>
|
||||
) : (
|
||||
<span class="flex items-center gap-1">
|
||||
<span class="text-base">🎤</span>
|
||||
<span class="font-medium">{props.speaker}</span>
|
||||
</span>
|
||||
)}
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>⏱️ {props.duration}</span>
|
||||
{props.date && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>{props.date}</span>
|
||||
</>
|
||||
)}
|
||||
{props.views && (
|
||||
<>
|
||||
<span class="text-theme-border">•</span>
|
||||
<span>👁️ {props.views}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Excerpt */}
|
||||
<p class="text-theme-text-muted mb-4 line-clamp-3 flex-1">{props.excerpt}</p>
|
||||
|
||||
{/* Tags */}
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
{props.tags.map((tag) => (
|
||||
<span class="px-3 py-1 bg-theme-surface rounded-full text-xs font-medium text-theme-text-muted border border-theme-border/50">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Text (no longer a link since whole card is clickable) */}
|
||||
<div class="inline-flex items-center gap-2 text-theme-primary font-semibold transition-all duration-300">
|
||||
<span>Weiterlesen</span>
|
||||
<svg
|
||||
class="w-4 h-4 transform group-hover:translate-x-1 transition-transform duration-300"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M13 7l5 5m0 0l-5 5m5-5H6"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative corner accent */}
|
||||
<div class="absolute top-0 right-0 w-20 h-20 bg-gradient-to-br from-theme-primary/10 to-transparent rounded-bl-[40px] opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||
</article>
|
||||
</a>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentCard;
|
||||
export default ContentCard;
|
||||
|
|
|
|||
|
|
@ -2,150 +2,156 @@ import { Component, For, createSignal, onMount } from 'solid-js';
|
|||
import ContentCard from './ContentCard';
|
||||
|
||||
interface Talk {
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
speakerId?: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const ContentCardList: Component = () => {
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt: 'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt: 'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt: 'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt: 'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt: 'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt: 'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k'
|
||||
}
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt:
|
||||
'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt:
|
||||
'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt:
|
||||
'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt:
|
||||
'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
speakerId: 'rory-sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt:
|
||||
'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k',
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={talks()}>
|
||||
{(talk) => (
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
speakerId={talk.speakerId}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div class="w-full">
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={talks()}>
|
||||
{(talk) => (
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
speakerId={talk.speakerId}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContentCardList;
|
||||
export default ContentCardList;
|
||||
|
|
|
|||
|
|
@ -3,103 +3,148 @@ const currentYear = new Date().getFullYear();
|
|||
---
|
||||
|
||||
<footer class="mt-24 border-t border-theme-border/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Main footer content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
<!-- About section -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span class="text-xl font-bold text-theme-text">Wisdom Library</span>
|
||||
</div>
|
||||
<p class="text-theme-text-muted text-sm leading-relaxed">
|
||||
Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer verfügbar.
|
||||
Powered by OpenAI Whisper für präzise Transkriptionen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Entdecken</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="/talks" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Alle Vorträge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/speakers" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Sprecher
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/categories" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Kategorien
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/trending" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Beliebt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Ressourcen</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="/admin" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Admin Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="http://localhost:8000/docs" target="_blank" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm flex items-center gap-1">
|
||||
API Dokumentation
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/about" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Über uns
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/contact" class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm">
|
||||
Kontakt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="pt-8 border-t border-theme-border/20">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex flex-col md:flex-row items-center gap-4 text-sm text-theme-text-muted">
|
||||
<span>© {currentYear} YouTube Wisdom Library</span>
|
||||
<span class="hidden md:inline">•</span>
|
||||
<span>Powered by OpenAI Whisper</span>
|
||||
</div>
|
||||
|
||||
<!-- Social links / Stats -->
|
||||
<div class="flex items-center gap-6 text-sm text-theme-text-muted">
|
||||
<a href="/privacy" class="hover:text-theme-primary transition-colors">
|
||||
Datenschutz
|
||||
</a>
|
||||
<a href="/terms" class="hover:text-theme-primary transition-colors">
|
||||
Nutzungsbedingungen
|
||||
</a>
|
||||
<a href="https://github.com" target="_blank" class="hover:text-theme-primary transition-colors">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Main footer content -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-8 mb-8">
|
||||
<!-- About section -->
|
||||
<div class="md:col-span-2">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span class="text-xl font-bold text-theme-text">Wisdom Library</span>
|
||||
</div>
|
||||
<p class="text-theme-text-muted text-sm leading-relaxed">
|
||||
Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer
|
||||
verfügbar. Powered by OpenAI Whisper für präzise Transkriptionen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Quick Links -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Entdecken</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a
|
||||
href="/talks"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Alle Vorträge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/speakers"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/categories"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/trending"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Beliebt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Resources -->
|
||||
<div>
|
||||
<h3 class="font-semibold text-theme-text mb-4">Ressourcen</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a
|
||||
href="/admin"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Admin Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm flex items-center gap-1"
|
||||
>
|
||||
API Dokumentation
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/about"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Über uns
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="/contact"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors text-sm"
|
||||
>
|
||||
Kontakt
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom bar -->
|
||||
<div class="pt-8 border-t border-theme-border/20">
|
||||
<div class="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div class="flex flex-col md:flex-row items-center gap-4 text-sm text-theme-text-muted">
|
||||
<span>© {currentYear} YouTube Wisdom Library</span>
|
||||
<span class="hidden md:inline">•</span>
|
||||
<span>Powered by OpenAI Whisper</span>
|
||||
</div>
|
||||
|
||||
<!-- Social links / Stats -->
|
||||
<div class="flex items-center gap-6 text-sm text-theme-text-muted">
|
||||
<a href="/privacy" class="hover:text-theme-primary transition-colors"> Datenschutz </a>
|
||||
<a href="/terms" class="hover:text-theme-primary transition-colors">
|
||||
Nutzungsbedingungen
|
||||
</a>
|
||||
<a
|
||||
href="https://github.com"
|
||||
target="_blank"
|
||||
class="hover:text-theme-primary transition-colors"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
|
|
|||
|
|
@ -1,108 +1,134 @@
|
|||
---
|
||||
export interface Props {
|
||||
currentPath?: string;
|
||||
currentPath?: string;
|
||||
}
|
||||
|
||||
const { currentPath = "/" } = Astro.props;
|
||||
const { currentPath = '/' } = Astro.props;
|
||||
---
|
||||
|
||||
<nav class="border-b border-theme-border/50 backdrop-blur-md sticky top-0 z-40 bg-theme-background/80">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<a href="/" class="text-xl font-bold flex items-center gap-2 text-theme-text hover:text-theme-primary transition-colors">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span>Wisdom Library</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
API
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden text-theme-text" id="mobile-menu-button">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="block py-2 text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
>
|
||||
API ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<nav
|
||||
class="border-b border-theme-border/50 backdrop-blur-md sticky top-0 z-40 bg-theme-background/80"
|
||||
>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex justify-between items-center h-16">
|
||||
<a
|
||||
href="/"
|
||||
class="text-xl font-bold flex items-center gap-2 text-theme-text hover:text-theme-primary transition-colors"
|
||||
>
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span>Wisdom Library</span>
|
||||
</a>
|
||||
|
||||
<div class="flex items-center gap-6">
|
||||
<div class="hidden md:flex items-center gap-6">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors flex items-center gap-1"
|
||||
>
|
||||
API
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
|
||||
></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu button -->
|
||||
<button class="md:hidden text-theme-text" id="mobile-menu-button">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile menu -->
|
||||
<div id="mobile-menu" class="hidden md:hidden pb-4">
|
||||
<a
|
||||
href="/talks"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/talks') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Vorträge
|
||||
</a>
|
||||
<a
|
||||
href="/speakers"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/speakers') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Sprecher
|
||||
</a>
|
||||
<a
|
||||
href="/categories"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/categories') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Kategorien
|
||||
</a>
|
||||
<a
|
||||
href="/admin"
|
||||
class={`block py-2 text-theme-text-muted hover:text-theme-primary transition-colors ${currentPath?.startsWith('/admin') ? 'text-theme-primary' : ''}`}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
<a
|
||||
href="http://localhost:8000/docs"
|
||||
target="_blank"
|
||||
class="block py-2 text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
>
|
||||
API ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
const mobileMenuButton = document.getElementById('mobile-menu-button');
|
||||
const mobileMenu = document.getElementById('mobile-menu');
|
||||
|
||||
mobileMenuButton?.addEventListener('click', () => {
|
||||
mobileMenu?.classList.toggle('hidden');
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -2,308 +2,342 @@ import { Component, For, createSignal, onMount, createMemo } from 'solid-js';
|
|||
import ContentCard from './ContentCard';
|
||||
|
||||
interface Talk {
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
speaker: string;
|
||||
duration: string;
|
||||
excerpt: string;
|
||||
tags: string[];
|
||||
link: string;
|
||||
date?: string;
|
||||
thumbnail?: string;
|
||||
views?: string;
|
||||
}
|
||||
|
||||
const SearchableContentList: Component = () => {
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [talks, setTalks] = createSignal<Talk[]>([]);
|
||||
const [loading, setLoading] = createSignal(true);
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt: 'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k'
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt: 'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k'
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt: 'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k'
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt: 'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k'
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt: 'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k'
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt: 'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k'
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'How Great Leaders Inspire Action (Start with Why)',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '18 Min',
|
||||
excerpt: 'Simon Sineks berühmter TED Talk über das Golden Circle Modell - warum großartige Führungskräfte mit dem "Warum" beginnen und wie dies das Verhalten und die Loyalität von Menschen beeinflusst.',
|
||||
tags: ['Leadership', 'Purpose', 'Golden Circle', 'Inspiration'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '60M+'
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Why Good Leaders Make You Feel Safe',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '12 Min',
|
||||
excerpt: 'Ein kraftvoller Vortrag darüber, wie echte Führung bedeutet, Sicherheit für das Team zu schaffen, damit Menschen ihr Bestes geben können und bereit sind, füreinander einzustehen.',
|
||||
tags: ['Leadership', 'Trust', 'Safety', 'Team Building'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '18M+'
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Millennials in the Workplace',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '15 Min',
|
||||
excerpt: 'Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation im Arbeitsplatz - von der Auswirkung der Technologie bis hin zu veränderten Arbeitserwartungen.',
|
||||
tags: ['Millennials', 'Workplace', 'Technology', 'Generational Change'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '100M+'
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Love Your Work',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '42 Min',
|
||||
excerpt: 'Ein inspirierender Talk über die Bedeutung von Leidenschaft bei der Arbeit und wie man eine Karriere aufbaut, die nicht nur erfolgreich, sondern auch erfüllend ist.',
|
||||
tags: ['Career', 'Passion', 'Purpose', 'Work-Life Balance'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '2.8M'
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'The Future of AI and Machine Learning',
|
||||
speaker: 'Andrew Ng',
|
||||
duration: '35 Min',
|
||||
excerpt: 'Ein tiefer Einblick in die Zukunft der künstlichen Intelligenz und wie Machine Learning unsere Welt verändern wird.',
|
||||
tags: ['AI', 'Machine Learning', 'Technology'],
|
||||
link: '/talks/andrew-ng-future-of-ai',
|
||||
date: '18. Februar 2024',
|
||||
views: '22.3k'
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
title: 'Building Resilient Systems',
|
||||
speaker: 'Martin Fowler',
|
||||
duration: '40 Min',
|
||||
excerpt: 'Wie man Software-Systeme baut, die robust, wartbar und skalierbar sind. Best Practices aus jahrzehntelanger Erfahrung.',
|
||||
tags: ['Software Architecture', 'Engineering', 'Best Practices'],
|
||||
link: '/talks/martin-fowler-resilient-systems',
|
||||
date: '15. Februar 2024',
|
||||
views: '9.8k'
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'The Psychology of Money',
|
||||
speaker: 'Morgan Housel',
|
||||
duration: '32 Min',
|
||||
excerpt: 'Warum kluge Menschen dumme Dinge mit Geld machen und wie unsere Psychologie unsere finanziellen Entscheidungen beeinflusst.',
|
||||
tags: ['Finance', 'Psychology', 'Behavioral Economics'],
|
||||
link: '/talks/morgan-housel-psychology-of-money',
|
||||
date: '10. Februar 2024',
|
||||
views: '25.6k'
|
||||
}
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
// Mock data - später durch API-Call ersetzen
|
||||
onMount(() => {
|
||||
// Simuliere API-Call
|
||||
setTimeout(() => {
|
||||
setTalks([
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Wie kleine Änderungen in der Perspektive große Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können. Ein faszinierender Einblick in die Verhaltensökonomie.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing'],
|
||||
link: '/talks/rory-sutherland-perspective-is-everything',
|
||||
date: '15. März 2024',
|
||||
views: '12.5k',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'The Power of Psychological Solutions',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '22 Min',
|
||||
excerpt:
|
||||
'Warum psychologische Lösungen oft effektiver und günstiger sind als technische. Sutherland zeigt, wie wir Probleme neu denken können.',
|
||||
tags: ['Innovation', 'Problem Solving', 'Design Thinking'],
|
||||
link: '/talks/rory-sutherland-psychological-solutions',
|
||||
date: '10. März 2024',
|
||||
views: '8.3k',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Marketing Secrets from Behavioral Science',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '25 Min',
|
||||
excerpt:
|
||||
'Die verborgenen psychologischen Mechanismen hinter erfolgreichem Marketing. Erkenntnisse aus jahrzehntelanger Erfahrung bei Ogilvy.',
|
||||
tags: ['Marketing', 'Consumer Behavior', 'Branding'],
|
||||
link: '/talks/rory-sutherland-marketing-secrets',
|
||||
date: '5. März 2024',
|
||||
views: '15.7k',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Why Context Matters More Than Content',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '20 Min',
|
||||
excerpt:
|
||||
'Der Kontext bestimmt, wie wir Informationen wahrnehmen und interpretieren. Eine Lektion in der Kunst der Kommunikation.',
|
||||
tags: ['Communication', 'Perception', 'Context'],
|
||||
link: '/talks/rory-sutherland-context-matters',
|
||||
date: '1. März 2024',
|
||||
views: '6.2k',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'The Irrational Consumer: Understanding Human Behavior',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '30 Min',
|
||||
excerpt:
|
||||
'Menschen sind keine rationalen Akteure. Wie wir diese Erkenntnis nutzen können, um bessere Produkte und Services zu entwickeln.',
|
||||
tags: ['Consumer Psychology', 'Behavioral Economics', 'UX Design'],
|
||||
link: '/talks/rory-sutherland-irrational-consumer',
|
||||
date: '25. Februar 2024',
|
||||
views: '10.1k',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Alchemy: The Magic of Ideas',
|
||||
speaker: 'Rory Sutherland',
|
||||
duration: '28 Min',
|
||||
excerpt:
|
||||
'Große Ideen kommen oft aus unerwarteten Ecken. Sutherland erklärt, warum Logik allein nicht ausreicht, um Innovation zu schaffen.',
|
||||
tags: ['Creativity', 'Innovation', 'Ideas'],
|
||||
link: '/talks/rory-sutherland-alchemy',
|
||||
date: '20. Februar 2024',
|
||||
views: '18.9k',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'How Great Leaders Inspire Action (Start with Why)',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '18 Min',
|
||||
excerpt:
|
||||
'Simon Sineks berühmter TED Talk über das Golden Circle Modell - warum großartige Führungskräfte mit dem "Warum" beginnen und wie dies das Verhalten und die Loyalität von Menschen beeinflusst.',
|
||||
tags: ['Leadership', 'Purpose', 'Golden Circle', 'Inspiration'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '60M+',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Why Good Leaders Make You Feel Safe',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '12 Min',
|
||||
excerpt:
|
||||
'Ein kraftvoller Vortrag darüber, wie echte Führung bedeutet, Sicherheit für das Team zu schaffen, damit Menschen ihr Bestes geben können und bereit sind, füreinander einzustehen.',
|
||||
tags: ['Leadership', 'Trust', 'Safety', 'Team Building'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '18M+',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Millennials in the Workplace',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '15 Min',
|
||||
excerpt:
|
||||
'Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation im Arbeitsplatz - von der Auswirkung der Technologie bis hin zu veränderten Arbeitserwartungen.',
|
||||
tags: ['Millennials', 'Workplace', 'Technology', 'Generational Change'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '100M+',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Love Your Work',
|
||||
speaker: 'Simon Sinek',
|
||||
duration: '42 Min',
|
||||
excerpt:
|
||||
'Ein inspirierender Talk über die Bedeutung von Leidenschaft bei der Arbeit und wie man eine Karriere aufbaut, die nicht nur erfolgreich, sondern auch erfüllend ist.',
|
||||
tags: ['Career', 'Passion', 'Purpose', 'Work-Life Balance'],
|
||||
link: '/speakers/simon-sinek',
|
||||
date: '9. September 2024',
|
||||
views: '2.8M',
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'The Future of AI and Machine Learning',
|
||||
speaker: 'Andrew Ng',
|
||||
duration: '35 Min',
|
||||
excerpt:
|
||||
'Ein tiefer Einblick in die Zukunft der künstlichen Intelligenz und wie Machine Learning unsere Welt verändern wird.',
|
||||
tags: ['AI', 'Machine Learning', 'Technology'],
|
||||
link: '/talks/andrew-ng-future-of-ai',
|
||||
date: '18. Februar 2024',
|
||||
views: '22.3k',
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
title: 'Building Resilient Systems',
|
||||
speaker: 'Martin Fowler',
|
||||
duration: '40 Min',
|
||||
excerpt:
|
||||
'Wie man Software-Systeme baut, die robust, wartbar und skalierbar sind. Best Practices aus jahrzehntelanger Erfahrung.',
|
||||
tags: ['Software Architecture', 'Engineering', 'Best Practices'],
|
||||
link: '/talks/martin-fowler-resilient-systems',
|
||||
date: '15. Februar 2024',
|
||||
views: '9.8k',
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'The Psychology of Money',
|
||||
speaker: 'Morgan Housel',
|
||||
duration: '32 Min',
|
||||
excerpt:
|
||||
'Warum kluge Menschen dumme Dinge mit Geld machen und wie unsere Psychologie unsere finanziellen Entscheidungen beeinflusst.',
|
||||
tags: ['Finance', 'Psychology', 'Behavioral Economics'],
|
||||
link: '/talks/morgan-housel-psychology-of-money',
|
||||
date: '10. Februar 2024',
|
||||
views: '25.6k',
|
||||
},
|
||||
]);
|
||||
setLoading(false);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
// Filtered talks based on search query
|
||||
const filteredTalks = createMemo(() => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query) return talks();
|
||||
|
||||
return talks().filter(talk => {
|
||||
return (
|
||||
talk.title.toLowerCase().includes(query) ||
|
||||
talk.speaker.toLowerCase().includes(query) ||
|
||||
talk.excerpt.toLowerCase().includes(query) ||
|
||||
talk.tags.some(tag => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
});
|
||||
// Filtered talks based on search query
|
||||
const filteredTalks = createMemo(() => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (!query) return talks();
|
||||
|
||||
// Handle search input
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setSearchQuery(target.value);
|
||||
};
|
||||
return talks().filter((talk) => {
|
||||
return (
|
||||
talk.title.toLowerCase().includes(query) ||
|
||||
talk.speaker.toLowerCase().includes(query) ||
|
||||
talk.excerpt.toLowerCase().includes(query) ||
|
||||
talk.tags.some((tag) => tag.toLowerCase().includes(query))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="w-full">
|
||||
{/* Search Bar */}
|
||||
<div class="mb-12 max-w-2xl mx-auto">
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Vorträgen, Sprechern oder Themen..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearch}
|
||||
class="w-full px-6 py-4 pl-12 glass rounded-full text-theme-text placeholder-theme-text-muted/60 border border-theme-border/50 focus:border-theme-primary/50 focus:outline-none focus:ring-2 focus:ring-theme-primary/20 transition-all"
|
||||
/>
|
||||
<svg class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-theme-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||
</svg>
|
||||
|
||||
{/* Clear button */}
|
||||
{searchQuery() && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-theme-surface transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg class="w-5 h-5 text-theme-text-muted" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{searchQuery() && !loading() && (
|
||||
<div class="mt-4 text-center text-theme-text-muted">
|
||||
{filteredTalks().length === 0 ? (
|
||||
<span>Keine Ergebnisse für "{searchQuery()}"</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredTalks().length} {filteredTalks().length === 1 ? 'Ergebnis' : 'Ergebnisse'} für "{searchQuery()}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
// Handle search input
|
||||
const handleSearch = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement;
|
||||
setSearchQuery(target.value);
|
||||
};
|
||||
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTalks().length === 0 && searchQuery() ? (
|
||||
// No results state
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<h3 class="text-2xl font-semibold mb-2 text-theme-text">Keine Treffer</h3>
|
||||
<p class="text-theme-text-muted max-w-md mx-auto">
|
||||
Versuche es mit anderen Suchbegriffen oder browse durch alle verfügbaren Vorträge.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="mt-6 px-6 py-2 bg-theme-primary text-white rounded-lg hover:bg-theme-primary-hover transition-colors"
|
||||
>
|
||||
Alle Vorträge anzeigen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid with fade-in animation
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={filteredTalks()}>
|
||||
{(talk, index) => (
|
||||
<div
|
||||
style={{
|
||||
animation: `fadeIn 0.5s ease-out ${index() * 0.05}s both`
|
||||
}}
|
||||
>
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
return (
|
||||
<div class="w-full">
|
||||
{/* Search Bar */}
|
||||
<div class="mb-12 max-w-2xl mx-auto">
|
||||
<div class="relative group">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Suche nach Vorträgen, Sprechern oder Themen..."
|
||||
value={searchQuery()}
|
||||
onInput={handleSearch}
|
||||
class="w-full px-6 py-4 pl-12 glass rounded-full text-theme-text placeholder-theme-text-muted/60 border border-theme-border/50 focus:border-theme-primary/50 focus:outline-none focus:ring-2 focus:ring-theme-primary/20 transition-all"
|
||||
/>
|
||||
<svg
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||
></path>
|
||||
</svg>
|
||||
|
||||
{/* Clear button */}
|
||||
{searchQuery() && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-theme-surface transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg
|
||||
class="w-5 h-5 text-theme-text-muted"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results count */}
|
||||
{searchQuery() && !loading() && (
|
||||
<div class="mt-4 text-center text-theme-text-muted">
|
||||
{filteredTalks().length === 0 ? (
|
||||
<span>Keine Ergebnisse für "{searchQuery()}"</span>
|
||||
) : (
|
||||
<span>
|
||||
{filteredTalks().length} {filteredTalks().length === 1 ? 'Ergebnis' : 'Ergebnisse'}{' '}
|
||||
für "{searchQuery()}"
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading() ? (
|
||||
// Loading skeleton
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={[1, 2, 3, 4, 5, 6]}>
|
||||
{() => (
|
||||
<div class="glass rounded-2xl overflow-hidden h-[460px] animate-pulse">
|
||||
<div class="h-48 bg-theme-surface"></div>
|
||||
<div class="p-6">
|
||||
<div class="h-6 bg-theme-surface rounded mb-3"></div>
|
||||
<div class="h-4 bg-theme-surface rounded w-2/3 mb-3"></div>
|
||||
<div class="space-y-2">
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded"></div>
|
||||
<div class="h-3 bg-theme-surface rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{filteredTalks().length === 0 && searchQuery() ? (
|
||||
// No results state
|
||||
<div class="text-center py-16">
|
||||
<div class="text-6xl mb-4">🔍</div>
|
||||
<h3 class="text-2xl font-semibold mb-2 text-theme-text">Keine Treffer</h3>
|
||||
<p class="text-theme-text-muted max-w-md mx-auto">
|
||||
Versuche es mit anderen Suchbegriffen oder browse durch alle verfügbaren Vorträge.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
class="mt-6 px-6 py-2 bg-theme-primary text-white rounded-lg hover:bg-theme-primary-hover transition-colors"
|
||||
>
|
||||
Alle Vorträge anzeigen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
// Content cards grid with fade-in animation
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
<For each={filteredTalks()}>
|
||||
{(talk, index) => (
|
||||
<div
|
||||
style={{
|
||||
animation: `fadeIn 0.5s ease-out ${index() * 0.05}s both`,
|
||||
}}
|
||||
>
|
||||
<ContentCard
|
||||
title={talk.title}
|
||||
speaker={talk.speaker}
|
||||
duration={talk.duration}
|
||||
excerpt={talk.excerpt}
|
||||
tags={talk.tags}
|
||||
link={talk.link}
|
||||
date={talk.date}
|
||||
thumbnail={talk.thumbnail}
|
||||
views={talk.views}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
|
@ -315,8 +349,8 @@ const SearchableContentList: Component = () => {
|
|||
}
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchableContentList;
|
||||
export default SearchableContentList;
|
||||
|
|
|
|||
|
|
@ -6,304 +6,306 @@ const currentPath = Astro.url.pathname;
|
|||
|
||||
// Sort talks by date (newest first)
|
||||
const sortedTalks = talks.sort((a, b) => {
|
||||
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
|
||||
return new Date(b.data.date).getTime() - new Date(a.data.date).getTime();
|
||||
});
|
||||
|
||||
// Group talks by speaker
|
||||
const talksBySpeaker = sortedTalks.reduce((acc, talk) => {
|
||||
const speaker = talk.data.speaker;
|
||||
if (!acc[speaker]) {
|
||||
acc[speaker] = [];
|
||||
}
|
||||
acc[speaker].push(talk);
|
||||
return acc;
|
||||
}, {} as Record<string, typeof talks>);
|
||||
const talksBySpeaker = sortedTalks.reduce(
|
||||
(acc, talk) => {
|
||||
const speaker = talk.data.speaker;
|
||||
if (!acc[speaker]) {
|
||||
acc[speaker] = [];
|
||||
}
|
||||
acc[speaker].push(talk);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof talks>
|
||||
);
|
||||
---
|
||||
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-section">
|
||||
<h1 class="logo">📚 Wisdom Library</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="/speakers" class="nav-item">
|
||||
<span class="nav-icon">🎤</span>
|
||||
<span>Speakers</span>
|
||||
</a>
|
||||
<a href="/admin" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="sidebar-header">
|
||||
<div class="logo-section">
|
||||
<h1 class="logo">📚 Wisdom Library</h1>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<a href="/" class="nav-item">
|
||||
<span class="nav-icon">🏠</span>
|
||||
<span>Home</span>
|
||||
</a>
|
||||
<a href="/speakers" class="nav-item">
|
||||
<span class="nav-icon">🎤</span>
|
||||
<span>Speakers</span>
|
||||
</a>
|
||||
<a href="/admin" class="nav-item">
|
||||
<span class="nav-icon">⚙️</span>
|
||||
<span>Admin</span>
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="talks-section">
|
||||
<div class="section-header">
|
||||
<h2>MY TALKS</h2>
|
||||
<button class="add-btn" title="Add new talk">+</button>
|
||||
</div>
|
||||
<div class="talks-section">
|
||||
<div class="section-header">
|
||||
<h2>MY TALKS</h2>
|
||||
<button class="add-btn" title="Add new talk">+</button>
|
||||
</div>
|
||||
|
||||
<div class="talks-list">
|
||||
{Object.entries(talksBySpeaker).map(([speaker, speakerTalks]) => (
|
||||
<div class="speaker-group">
|
||||
<div class="speaker-header">
|
||||
<span class="speaker-name">{speaker}</span>
|
||||
<span class="talk-count">{speakerTalks.length}</span>
|
||||
</div>
|
||||
{speakerTalks.map(talk => {
|
||||
const isActive = currentPath.includes(talk.slug);
|
||||
return (
|
||||
<a
|
||||
href={`/talks/${talk.slug}`}
|
||||
class={`talk-card ${isActive ? 'active' : ''}`}
|
||||
>
|
||||
<div class="talk-title">{talk.data.title}</div>
|
||||
<div class="talk-meta">
|
||||
<span class="talk-tag">{talk.data.category.replace('-', ' ')}</span>
|
||||
<span class="talk-date">
|
||||
{new Date(talk.data.date).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="talk-summary">{talk.data.summary.substring(0, 120)}...</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div class="talks-list">
|
||||
{
|
||||
Object.entries(talksBySpeaker).map(([speaker, speakerTalks]) => (
|
||||
<div class="speaker-group">
|
||||
<div class="speaker-header">
|
||||
<span class="speaker-name">{speaker}</span>
|
||||
<span class="talk-count">{speakerTalks.length}</span>
|
||||
</div>
|
||||
{speakerTalks.map((talk) => {
|
||||
const isActive = currentPath.includes(talk.slug);
|
||||
return (
|
||||
<a href={`/talks/${talk.slug}`} class={`talk-card ${isActive ? 'active' : ''}`}>
|
||||
<div class="talk-title">{talk.data.title}</div>
|
||||
<div class="talk-meta">
|
||||
<span class="talk-tag">{talk.data.category.replace('-', ' ')}</span>
|
||||
<span class="talk-date">
|
||||
{new Date(talk.data.date).toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div class="talk-summary">{talk.data.summary.substring(0, 120)}...</div>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
height: 100vh;
|
||||
background: rgb(var(--theme-card));
|
||||
border-right: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar {
|
||||
width: 320px;
|
||||
height: 100vh;
|
||||
background: rgb(var(--theme-card));
|
||||
border-right: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.sidebar-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.logo-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.logo-section {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.logo {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.sidebar-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-decoration: none;
|
||||
transition: all 0.2s ease;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: rgba(var(--theme-primary), 0.08);
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: rgba(var(--theme-primary), 0.08);
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
.nav-icon {
|
||||
font-size: 1.1rem;
|
||||
width: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.talks-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.talks-section {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.section-header {
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
.section-header h2 {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.add-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
.add-btn:hover {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.talks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
.talks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.talks-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.talks-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
.talks-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(var(--theme-primary), 0.2);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.talks-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--theme-primary), 0.3);
|
||||
}
|
||||
.talks-list::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(var(--theme-primary), 0.3);
|
||||
}
|
||||
|
||||
.speaker-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.speaker-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.speaker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.speaker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.talk-count {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.talk-count {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
color: rgb(var(--theme-primary));
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 10px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.talk-card {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
margin: 0.25rem 0.5rem;
|
||||
background: rgba(var(--theme-background), 0.5);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.08);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.talk-card {
|
||||
display: block;
|
||||
padding: 0.75rem;
|
||||
margin: 0.25rem 0.5rem;
|
||||
background: rgba(var(--theme-background), 0.5);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.08);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.talk-card:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-color: rgba(var(--theme-primary), 0.15);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.talk-card:hover {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-color: rgba(var(--theme-primary), 0.15);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.talk-card.active {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
border-color: rgb(var(--theme-primary));
|
||||
border-left-width: 3px;
|
||||
}
|
||||
.talk-card.active {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
border-color: rgb(var(--theme-primary));
|
||||
border-left-width: 3px;
|
||||
}
|
||||
|
||||
.talk-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text));
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.talk-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-text));
|
||||
margin-bottom: 0.25rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.talk-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.talk-meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.talk-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: rgba(var(--theme-secondary), 0.1);
|
||||
color: rgb(var(--theme-secondary));
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.talk-tag {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: rgba(var(--theme-secondary), 0.1);
|
||||
color: rgb(var(--theme-secondary));
|
||||
border-radius: 4px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.talk-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
.talk-date {
|
||||
font-size: 0.7rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
|
||||
.talk-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
line-height: 1.4;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.talk-summary {
|
||||
font-size: 0.75rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
line-height: 1.4;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s ease;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,257 +1,274 @@
|
|||
---
|
||||
|
||||
---
|
||||
|
||||
<div class="fixed top-4 right-4 z-50 flex items-center gap-2">
|
||||
<!-- Theme Selector -->
|
||||
<div class="relative">
|
||||
<button
|
||||
id="theme-menu-button"
|
||||
class="glass px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Select theme"
|
||||
>
|
||||
<span id="theme-icon">🌊</span>
|
||||
<span id="theme-name" class="hidden sm:inline">Ocean</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
id="theme-menu"
|
||||
class="absolute right-0 mt-2 w-48 glass rounded-lg shadow-theme-lg hidden opacity-0 transform scale-95 transition-all duration-200"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button data-theme="ocean" class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text">
|
||||
<span>🌊</span> Ocean
|
||||
</button>
|
||||
<button data-theme="forest" class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text">
|
||||
<span>🌲</span> Forest
|
||||
</button>
|
||||
<button data-theme="sunset" class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text">
|
||||
<span>🌅</span> Sunset
|
||||
</button>
|
||||
<button data-theme="monochrome" class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text">
|
||||
<span>⚫</span> Monochrome
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Theme Selector -->
|
||||
<div class="relative">
|
||||
<button
|
||||
id="theme-menu-button"
|
||||
class="glass px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Select theme"
|
||||
>
|
||||
<span id="theme-icon">🌊</span>
|
||||
<span id="theme-name" class="hidden sm:inline">Ocean</span>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
id="dark-toggle"
|
||||
class="glass p-2 rounded-lg hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<svg id="sun-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg id="moon-icon" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div
|
||||
id="theme-menu"
|
||||
class="absolute right-0 mt-2 w-48 glass rounded-lg shadow-theme-lg hidden opacity-0 transform scale-95 transition-all duration-200"
|
||||
>
|
||||
<div class="p-2">
|
||||
<button
|
||||
data-theme="ocean"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌊</span> Ocean
|
||||
</button>
|
||||
<button
|
||||
data-theme="forest"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌲</span> Forest
|
||||
</button>
|
||||
<button
|
||||
data-theme="sunset"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>🌅</span> Sunset
|
||||
</button>
|
||||
<button
|
||||
data-theme="monochrome"
|
||||
class="theme-option w-full px-4 py-2 rounded-md hover:bg-theme-surface-hover text-left flex items-center gap-3 text-theme-text"
|
||||
>
|
||||
<span>⚫</span> Monochrome
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dark Mode Toggle -->
|
||||
<button
|
||||
id="dark-toggle"
|
||||
class="glass p-2 rounded-lg hover:bg-theme-surface-hover text-theme-text"
|
||||
aria-label="Toggle dark mode"
|
||||
>
|
||||
<svg id="sun-icon" class="w-5 h-5 hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<svg id="moon-icon" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const themes = {
|
||||
ocean: { icon: '🌊', name: 'Ocean' },
|
||||
forest: { icon: '🌲', name: 'Forest' },
|
||||
sunset: { icon: '🌅', name: 'Sunset' },
|
||||
monochrome: { icon: '⚫', name: 'Monochrome' }
|
||||
};
|
||||
const themes = {
|
||||
ocean: { icon: '🌊', name: 'Ocean' },
|
||||
forest: { icon: '🌲', name: 'Forest' },
|
||||
sunset: { icon: '🌅', name: 'Sunset' },
|
||||
monochrome: { icon: '⚫', name: 'Monochrome' },
|
||||
};
|
||||
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = 'ocean';
|
||||
this.isDark = false;
|
||||
this.init();
|
||||
}
|
||||
class ThemeManager {
|
||||
constructor() {
|
||||
this.currentTheme = 'ocean';
|
||||
this.isDark = false;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Load saved preferences
|
||||
this.loadPreferences();
|
||||
|
||||
// Apply theme immediately
|
||||
this.applyTheme();
|
||||
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
// Listen for system theme changes
|
||||
this.watchSystemPreference();
|
||||
}
|
||||
init() {
|
||||
// Load saved preferences
|
||||
this.loadPreferences();
|
||||
|
||||
loadPreferences() {
|
||||
// Check localStorage first
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
|
||||
if (savedTheme && themes[savedTheme]) {
|
||||
this.currentTheme = savedTheme;
|
||||
}
|
||||
|
||||
if (savedMode !== null) {
|
||||
this.isDark = savedMode === 'true';
|
||||
} else {
|
||||
// Check system preference if no saved preference
|
||||
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
}
|
||||
// Apply theme immediately
|
||||
this.applyTheme();
|
||||
|
||||
savePreferences() {
|
||||
localStorage.setItem('theme', this.currentTheme);
|
||||
localStorage.setItem('darkMode', this.isDark.toString());
|
||||
}
|
||||
// Setup event listeners
|
||||
this.setupEventListeners();
|
||||
|
||||
applyTheme() {
|
||||
const html = document.documentElement;
|
||||
|
||||
// Set theme
|
||||
html.setAttribute('data-theme', this.currentTheme);
|
||||
|
||||
// Set dark mode
|
||||
if (this.isDark) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updateUI();
|
||||
}
|
||||
// Listen for system theme changes
|
||||
this.watchSystemPreference();
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// Update theme button
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeName = document.getElementById('theme-name');
|
||||
if (themeIcon && themeName) {
|
||||
themeIcon.textContent = themes[this.currentTheme].icon;
|
||||
themeName.textContent = themes[this.currentTheme].name;
|
||||
}
|
||||
|
||||
// Update dark mode toggle
|
||||
const sunIcon = document.getElementById('sun-icon');
|
||||
const moonIcon = document.getElementById('moon-icon');
|
||||
if (sunIcon && moonIcon) {
|
||||
if (this.isDark) {
|
||||
sunIcon.classList.remove('hidden');
|
||||
moonIcon.classList.add('hidden');
|
||||
} else {
|
||||
sunIcon.classList.add('hidden');
|
||||
moonIcon.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update active theme in menu
|
||||
document.querySelectorAll('.theme-option').forEach(btn => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme === this.currentTheme) {
|
||||
btn.classList.add('bg-theme-primary/10', 'text-theme-primary');
|
||||
} else {
|
||||
btn.classList.remove('bg-theme-primary/10', 'text-theme-primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
loadPreferences() {
|
||||
// Check localStorage first
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const savedMode = localStorage.getItem('darkMode');
|
||||
|
||||
setupEventListeners() {
|
||||
// Theme menu toggle
|
||||
const menuButton = document.getElementById('theme-menu-button');
|
||||
const menu = document.getElementById('theme-menu');
|
||||
|
||||
if (menuButton && menu) {
|
||||
menuButton.addEventListener('click', () => {
|
||||
const isHidden = menu.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
menu.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.remove('opacity-0', 'scale-95');
|
||||
menu.classList.add('opacity-100', 'scale-100');
|
||||
}, 10);
|
||||
} else {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton.contains(e.target) && !menu.contains(e.target)) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Theme selection
|
||||
document.querySelectorAll('.theme-option').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme && themes[theme]) {
|
||||
this.currentTheme = theme;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('theme-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle
|
||||
const darkToggle = document.getElementById('dark-toggle');
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', () => {
|
||||
this.isDark = !this.isDark;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Alt/Option + T: Open theme menu
|
||||
if (e.altKey && e.key === 't') {
|
||||
e.preventDefault();
|
||||
menuButton?.click();
|
||||
}
|
||||
|
||||
// Alt/Option + D: Toggle dark mode
|
||||
if (e.altKey && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
darkToggle?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
if (savedTheme && themes[savedTheme]) {
|
||||
this.currentTheme = savedTheme;
|
||||
}
|
||||
|
||||
watchSystemPreference() {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
// Only apply if user hasn't set a preference
|
||||
if (localStorage.getItem('darkMode') === null) {
|
||||
this.isDark = e.matches;
|
||||
this.applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (savedMode !== null) {
|
||||
this.isDark = savedMode === 'true';
|
||||
} else {
|
||||
// Check system preference if no saved preference
|
||||
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme manager when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new ThemeManager());
|
||||
} else {
|
||||
new ThemeManager();
|
||||
}
|
||||
</script>
|
||||
savePreferences() {
|
||||
localStorage.setItem('theme', this.currentTheme);
|
||||
localStorage.setItem('darkMode', this.isDark.toString());
|
||||
}
|
||||
|
||||
applyTheme() {
|
||||
const html = document.documentElement;
|
||||
|
||||
// Set theme
|
||||
html.setAttribute('data-theme', this.currentTheme);
|
||||
|
||||
// Set dark mode
|
||||
if (this.isDark) {
|
||||
html.classList.add('dark');
|
||||
} else {
|
||||
html.classList.remove('dark');
|
||||
}
|
||||
|
||||
// Update UI
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
updateUI() {
|
||||
// Update theme button
|
||||
const themeIcon = document.getElementById('theme-icon');
|
||||
const themeName = document.getElementById('theme-name');
|
||||
if (themeIcon && themeName) {
|
||||
themeIcon.textContent = themes[this.currentTheme].icon;
|
||||
themeName.textContent = themes[this.currentTheme].name;
|
||||
}
|
||||
|
||||
// Update dark mode toggle
|
||||
const sunIcon = document.getElementById('sun-icon');
|
||||
const moonIcon = document.getElementById('moon-icon');
|
||||
if (sunIcon && moonIcon) {
|
||||
if (this.isDark) {
|
||||
sunIcon.classList.remove('hidden');
|
||||
moonIcon.classList.add('hidden');
|
||||
} else {
|
||||
sunIcon.classList.add('hidden');
|
||||
moonIcon.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// Update active theme in menu
|
||||
document.querySelectorAll('.theme-option').forEach((btn) => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme === this.currentTheme) {
|
||||
btn.classList.add('bg-theme-primary/10', 'text-theme-primary');
|
||||
} else {
|
||||
btn.classList.remove('bg-theme-primary/10', 'text-theme-primary');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Theme menu toggle
|
||||
const menuButton = document.getElementById('theme-menu-button');
|
||||
const menu = document.getElementById('theme-menu');
|
||||
|
||||
if (menuButton && menu) {
|
||||
menuButton.addEventListener('click', () => {
|
||||
const isHidden = menu.classList.contains('hidden');
|
||||
if (isHidden) {
|
||||
menu.classList.remove('hidden');
|
||||
setTimeout(() => {
|
||||
menu.classList.remove('opacity-0', 'scale-95');
|
||||
menu.classList.add('opacity-100', 'scale-100');
|
||||
}, 10);
|
||||
} else {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
|
||||
// Close menu when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!menuButton.contains(e.target) && !menu.contains(e.target)) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Theme selection
|
||||
document.querySelectorAll('.theme-option').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const theme = btn.getAttribute('data-theme');
|
||||
if (theme && themes[theme]) {
|
||||
this.currentTheme = theme;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
|
||||
// Close menu
|
||||
const menu = document.getElementById('theme-menu');
|
||||
if (menu) {
|
||||
menu.classList.remove('opacity-100', 'scale-100');
|
||||
menu.classList.add('opacity-0', 'scale-95');
|
||||
setTimeout(() => {
|
||||
menu.classList.add('hidden');
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Dark mode toggle
|
||||
const darkToggle = document.getElementById('dark-toggle');
|
||||
if (darkToggle) {
|
||||
darkToggle.addEventListener('click', () => {
|
||||
this.isDark = !this.isDark;
|
||||
this.applyTheme();
|
||||
this.savePreferences();
|
||||
});
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Alt/Option + T: Open theme menu
|
||||
if (e.altKey && e.key === 't') {
|
||||
e.preventDefault();
|
||||
menuButton?.click();
|
||||
}
|
||||
|
||||
// Alt/Option + D: Toggle dark mode
|
||||
if (e.altKey && e.key === 'd') {
|
||||
e.preventDefault();
|
||||
darkToggle?.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
watchSystemPreference() {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
mediaQuery.addEventListener('change', (e) => {
|
||||
// Only apply if user hasn't set a preference
|
||||
if (localStorage.getItem('darkMode') === null) {
|
||||
this.isDark = e.matches;
|
||||
this.applyTheme();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize theme manager when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => new ThemeManager());
|
||||
} else {
|
||||
new ThemeManager();
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -1,225 +1,235 @@
|
|||
import { createSignal, createEffect, onMount, For } from 'solid-js';
|
||||
|
||||
interface Job {
|
||||
id: string;
|
||||
url: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
created_at: string;
|
||||
video_info: any;
|
||||
id: string;
|
||||
url: string;
|
||||
status: string;
|
||||
progress: number;
|
||||
created_at: string;
|
||||
video_info: any;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
total_transcripts: number;
|
||||
total_size_mb: number;
|
||||
active_jobs: number;
|
||||
completed_jobs: number;
|
||||
failed_jobs: number;
|
||||
total_transcripts: number;
|
||||
total_size_mb: number;
|
||||
active_jobs: number;
|
||||
completed_jobs: number;
|
||||
failed_jobs: number;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export default function Dashboard() {
|
||||
const [jobs, setJobs] = createSignal<Job[]>([]);
|
||||
const [stats, setStats] = createSignal<Stats | null>(null);
|
||||
const [newUrl, setNewUrl] = createSignal('');
|
||||
const [selectedModel, setSelectedModel] = createSignal('base');
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [ws, setWs] = createSignal<WebSocket | null>(null);
|
||||
const [jobs, setJobs] = createSignal<Job[]>([]);
|
||||
const [stats, setStats] = createSignal<Stats | null>(null);
|
||||
const [newUrl, setNewUrl] = createSignal('');
|
||||
const [selectedModel, setSelectedModel] = createSignal('base');
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [ws, setWs] = createSignal<WebSocket | null>(null);
|
||||
|
||||
onMount(() => {
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
connectWebSocket();
|
||||
});
|
||||
onMount(() => {
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
connectWebSocket();
|
||||
});
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const websocket = new WebSocket(`ws://localhost:8000/ws/progress`);
|
||||
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
const connectWebSocket = () => {
|
||||
const websocket = new WebSocket(`ws://localhost:8000/ws/progress`);
|
||||
|
||||
websocket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'job_update' || data.type === 'job_complete') {
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
}
|
||||
};
|
||||
websocket.onopen = () => {
|
||||
console.log('WebSocket connected');
|
||||
};
|
||||
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
websocket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'job_update' || data.type === 'job_complete') {
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
}
|
||||
};
|
||||
|
||||
setWs(websocket);
|
||||
};
|
||||
websocket.onerror = (error) => {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/jobs`);
|
||||
const data = await response.json();
|
||||
setJobs(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching jobs:', error);
|
||||
}
|
||||
};
|
||||
setWs(websocket);
|
||||
};
|
||||
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/stats`);
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
}
|
||||
};
|
||||
const fetchJobs = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/jobs`);
|
||||
const data = await response.json();
|
||||
setJobs(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching jobs:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const startTranscription = async () => {
|
||||
if (!newUrl()) return;
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/stats`);
|
||||
const data = await response.json();
|
||||
setStats(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error);
|
||||
}
|
||||
};
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: newUrl(),
|
||||
model: selectedModel(),
|
||||
language: 'de'
|
||||
}),
|
||||
});
|
||||
const startTranscription = async () => {
|
||||
if (!newUrl()) return;
|
||||
|
||||
if (response.ok) {
|
||||
setNewUrl('');
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting transcription:', error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: newUrl(),
|
||||
model: selectedModel(),
|
||||
language: 'de',
|
||||
}),
|
||||
});
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return 'text-yellow-400';
|
||||
case 'downloading': return 'text-blue-400';
|
||||
case 'transcribing': return 'text-purple-400';
|
||||
case 'completed': return 'text-green-400';
|
||||
case 'failed': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
if (response.ok) {
|
||||
setNewUrl('');
|
||||
fetchJobs();
|
||||
fetchStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error starting transcription:', error);
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending': return '⏳';
|
||||
case 'downloading': return '⬇️';
|
||||
case 'transcribing': return '🎙️';
|
||||
case 'completed': return '✅';
|
||||
case 'failed': return '❌';
|
||||
default: return '❓';
|
||||
}
|
||||
};
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return 'text-yellow-400';
|
||||
case 'downloading':
|
||||
return 'text-blue-400';
|
||||
case 'transcribing':
|
||||
return 'text-purple-400';
|
||||
case 'completed':
|
||||
return 'text-green-400';
|
||||
case 'failed':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-white">{stats()?.total_transcripts || 0}</div>
|
||||
<div class="text-sm text-gray-400">Transkripte</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-white">{stats()?.total_size_mb || 0} MB</div>
|
||||
<div class="text-sm text-gray-400">Speicher</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-yellow-400">{stats()?.active_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Aktiv</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-400">{stats()?.completed_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Fertig</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-red-400">{stats()?.failed_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return '⏳';
|
||||
case 'downloading':
|
||||
return '⬇️';
|
||||
case 'transcribing':
|
||||
return '🎙️';
|
||||
case 'completed':
|
||||
return '✅';
|
||||
case 'failed':
|
||||
return '❌';
|
||||
default:
|
||||
return '❓';
|
||||
}
|
||||
};
|
||||
|
||||
{/* New Transcription Form */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Neue Transkription</h2>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newUrl()}
|
||||
onInput={(e) => setNewUrl(e.currentTarget.value)}
|
||||
placeholder="YouTube URL eingeben..."
|
||||
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={selectedModel()}
|
||||
onChange={(e) => setSelectedModel(e.currentTarget.value)}
|
||||
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="tiny">Tiny (Schnell)</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large (Beste Qualität)</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={startTranscription}
|
||||
disabled={isLoading() || !newUrl()}
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading() ? 'Lädt...' : 'Starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Stats Cards */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-white">{stats()?.total_transcripts || 0}</div>
|
||||
<div class="text-sm text-gray-400">Transkripte</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-white">{stats()?.total_size_mb || 0} MB</div>
|
||||
<div class="text-sm text-gray-400">Speicher</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-yellow-400">{stats()?.active_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Aktiv</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-400">{stats()?.completed_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Fertig</div>
|
||||
</div>
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="text-2xl font-bold text-red-400">{stats()?.failed_jobs || 0}</div>
|
||||
<div class="text-sm text-gray-400">Fehler</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Aktive Jobs</h2>
|
||||
<div class="space-y-4">
|
||||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<div class="bg-gray-700 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xl">{getStatusIcon(job.status)}</span>
|
||||
<span class={`font-semibold ${getStatusColor(job.status)}`}>
|
||||
{job.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-300 mb-2 truncate">{job.url}</div>
|
||||
{job.status !== 'completed' && job.status !== 'failed' && (
|
||||
<div class="w-full bg-gray-600 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${job.progress}%`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{jobs().length === 0 && (
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
Keine aktiven Jobs
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* New Transcription Form */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Neue Transkription</h2>
|
||||
<div class="flex gap-4">
|
||||
<input
|
||||
type="text"
|
||||
value={newUrl()}
|
||||
onInput={(e) => setNewUrl(e.currentTarget.value)}
|
||||
placeholder="YouTube URL eingeben..."
|
||||
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={selectedModel()}
|
||||
onChange={(e) => setSelectedModel(e.currentTarget.value)}
|
||||
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="tiny">Tiny (Schnell)</option>
|
||||
<option value="base">Base</option>
|
||||
<option value="small">Small</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="large">Large (Beste Qualität)</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={startTranscription}
|
||||
disabled={isLoading() || !newUrl()}
|
||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading() ? 'Lädt...' : 'Starten'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active Jobs */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Aktive Jobs</h2>
|
||||
<div class="space-y-4">
|
||||
<For each={jobs()}>
|
||||
{(job) => (
|
||||
<div class="bg-gray-700 p-4 rounded-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xl">{getStatusIcon(job.status)}</span>
|
||||
<span class={`font-semibold ${getStatusColor(job.status)}`}>
|
||||
{job.status.toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">
|
||||
{new Date(job.created_at).toLocaleString('de-DE')}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-300 mb-2 truncate">{job.url}</div>
|
||||
{job.status !== 'completed' && job.status !== 'failed' && (
|
||||
<div class="w-full bg-gray-600 rounded-full h-2">
|
||||
<div
|
||||
class="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={`width: ${job.progress}%`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{jobs().length === 0 && (
|
||||
<div class="text-center text-gray-400 py-8">Keine aktiven Jobs</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,256 +1,263 @@
|
|||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
|
||||
interface Playlist {
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
url_count: number;
|
||||
urls: string[];
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
url_count: number;
|
||||
urls: string[];
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export default function PlaylistManager() {
|
||||
const [playlists, setPlaylists] = createSignal<Playlist[]>([]);
|
||||
const [selectedPlaylist, setSelectedPlaylist] = createSignal<Playlist | null>(null);
|
||||
const [newPlaylistName, setNewPlaylistName] = createSignal('');
|
||||
const [newPlaylistCategory, setNewPlaylistCategory] = createSignal('general');
|
||||
const [newUrls, setNewUrls] = createSignal('');
|
||||
const [isCreating, setIsCreating] = createSignal(false);
|
||||
const [isProcessing, setIsProcessing] = createSignal(false);
|
||||
const [playlists, setPlaylists] = createSignal<Playlist[]>([]);
|
||||
const [selectedPlaylist, setSelectedPlaylist] = createSignal<Playlist | null>(null);
|
||||
const [newPlaylistName, setNewPlaylistName] = createSignal('');
|
||||
const [newPlaylistCategory, setNewPlaylistCategory] = createSignal('general');
|
||||
const [newUrls, setNewUrls] = createSignal('');
|
||||
const [isCreating, setIsCreating] = createSignal(false);
|
||||
const [isProcessing, setIsProcessing] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
fetchPlaylists();
|
||||
});
|
||||
onMount(() => {
|
||||
fetchPlaylists();
|
||||
});
|
||||
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/playlists`);
|
||||
const data = await response.json();
|
||||
setPlaylists(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlists:', error);
|
||||
}
|
||||
};
|
||||
const fetchPlaylists = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/playlists`);
|
||||
const data = await response.json();
|
||||
setPlaylists(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching playlists:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createPlaylist = async () => {
|
||||
if (!newPlaylistName() || !newUrls()) return;
|
||||
const createPlaylist = async () => {
|
||||
if (!newPlaylistName() || !newUrls()) return;
|
||||
|
||||
try {
|
||||
const urls = newUrls().split('\n').filter(url => url.trim());
|
||||
const name = newPlaylistCategory() === 'general'
|
||||
? newPlaylistName()
|
||||
: `${newPlaylistCategory()}/${newPlaylistName()}`;
|
||||
try {
|
||||
const urls = newUrls()
|
||||
.split('\n')
|
||||
.filter((url) => url.trim());
|
||||
const name =
|
||||
newPlaylistCategory() === 'general'
|
||||
? newPlaylistName()
|
||||
: `${newPlaylistCategory()}/${newPlaylistName()}`;
|
||||
|
||||
const response = await fetch(`${API_URL}/api/playlists`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
urls: urls
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${API_URL}/api/playlists`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
urls: urls,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setNewPlaylistName('');
|
||||
setNewUrls('');
|
||||
setIsCreating(false);
|
||||
fetchPlaylists();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating playlist:', error);
|
||||
}
|
||||
};
|
||||
if (response.ok) {
|
||||
setNewPlaylistName('');
|
||||
setNewUrls('');
|
||||
setIsCreating(false);
|
||||
fetchPlaylists();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating playlist:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const processPlaylist = async (playlist: Playlist) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Process each URL in the playlist
|
||||
for (const url of playlist.urls) {
|
||||
await fetch(`${API_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
model: 'large',
|
||||
language: 'de'
|
||||
}),
|
||||
});
|
||||
}
|
||||
alert(`Started processing ${playlist.url_count} videos from ${playlist.name}`);
|
||||
} catch (error) {
|
||||
console.error('Error processing playlist:', error);
|
||||
}
|
||||
setIsProcessing(false);
|
||||
};
|
||||
const processPlaylist = async (playlist: Playlist) => {
|
||||
setIsProcessing(true);
|
||||
try {
|
||||
// Process each URL in the playlist
|
||||
for (const url of playlist.urls) {
|
||||
await fetch(`${API_URL}/api/transcribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
model: 'large',
|
||||
language: 'de',
|
||||
}),
|
||||
});
|
||||
}
|
||||
alert(`Started processing ${playlist.url_count} videos from ${playlist.name}`);
|
||||
} catch (error) {
|
||||
console.error('Error processing playlist:', error);
|
||||
}
|
||||
setIsProcessing(false);
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: { [key: string]: string } = {
|
||||
'tech': 'bg-blue-900',
|
||||
'people': 'bg-purple-900',
|
||||
'musik': 'bg-pink-900',
|
||||
'gaming': 'bg-green-900',
|
||||
'general': 'bg-gray-800'
|
||||
};
|
||||
return colors[category] || 'bg-gray-800';
|
||||
};
|
||||
const getCategoryColor = (category: string) => {
|
||||
const colors: { [key: string]: string } = {
|
||||
tech: 'bg-blue-900',
|
||||
people: 'bg-purple-900',
|
||||
musik: 'bg-pink-900',
|
||||
gaming: 'bg-green-900',
|
||||
general: 'bg-gray-800',
|
||||
};
|
||||
return colors[category] || 'bg-gray-800';
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons: { [key: string]: string } = {
|
||||
'tech': '💻',
|
||||
'people': '👥',
|
||||
'musik': '🎵',
|
||||
'gaming': '🎮',
|
||||
'general': '📁'
|
||||
};
|
||||
return icons[category] || '📁';
|
||||
};
|
||||
const getCategoryIcon = (category: string) => {
|
||||
const icons: { [key: string]: string } = {
|
||||
tech: '💻',
|
||||
people: '👥',
|
||||
musik: '🎵',
|
||||
gaming: '🎮',
|
||||
general: '📁',
|
||||
};
|
||||
return icons[category] || '📁';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header with Create Button */}
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Playlists</h1>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
+ Neue Playlist
|
||||
</button>
|
||||
</div>
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header with Create Button */}
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-2xl font-bold">Playlists</h1>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
+ Neue Playlist
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Create New Playlist Form */}
|
||||
<Show when={isCreating()}>
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Neue Playlist erstellen</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4">
|
||||
<select
|
||||
value={newPlaylistCategory()}
|
||||
onChange={(e) => setNewPlaylistCategory(e.currentTarget.value)}
|
||||
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="general">General</option>
|
||||
<option value="tech">Tech</option>
|
||||
<option value="people">People</option>
|
||||
<option value="musik">Musik</option>
|
||||
<option value="gaming">Gaming</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newPlaylistName()}
|
||||
onInput={(e) => setNewPlaylistName(e.currentTarget.value)}
|
||||
placeholder="Playlist Name..."
|
||||
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={newUrls()}
|
||||
onInput={(e) => setNewUrls(e.currentTarget.value)}
|
||||
placeholder="YouTube URLs (eine pro Zeile)..."
|
||||
rows={6}
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylistName() || !newUrls()}
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewPlaylistName('');
|
||||
setNewUrls('');
|
||||
}}
|
||||
class="px-6 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Create New Playlist Form */}
|
||||
<Show when={isCreating()}>
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Neue Playlist erstellen</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex gap-4">
|
||||
<select
|
||||
value={newPlaylistCategory()}
|
||||
onChange={(e) => setNewPlaylistCategory(e.currentTarget.value)}
|
||||
class="px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="general">General</option>
|
||||
<option value="tech">Tech</option>
|
||||
<option value="people">People</option>
|
||||
<option value="musik">Musik</option>
|
||||
<option value="gaming">Gaming</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={newPlaylistName()}
|
||||
onInput={(e) => setNewPlaylistName(e.currentTarget.value)}
|
||||
placeholder="Playlist Name..."
|
||||
class="flex-1 px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
value={newUrls()}
|
||||
onInput={(e) => setNewUrls(e.currentTarget.value)}
|
||||
placeholder="YouTube URLs (eine pro Zeile)..."
|
||||
rows={6}
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<div class="flex gap-4">
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylistName() || !newUrls()}
|
||||
class="px-6 py-2 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
Erstellen
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsCreating(false);
|
||||
setNewPlaylistName('');
|
||||
setNewUrls('');
|
||||
}}
|
||||
class="px-6 py-2 bg-gray-600 text-white rounded-md hover:bg-gray-700"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Playlists Grid */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={playlists()}>
|
||||
{(playlist) => (
|
||||
<div class={`${getCategoryColor(playlist.category)} p-6 rounded-lg cursor-pointer hover:opacity-90 transition-opacity`}
|
||||
onClick={() => setSelectedPlaylist(playlist)}>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-2xl">{getCategoryIcon(playlist.category)}</span>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg">{playlist.name}</h3>
|
||||
<p class="text-sm text-gray-400">{playlist.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="bg-gray-700 px-2 py-1 rounded text-sm">
|
||||
{playlist.url_count} Videos
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
processPlaylist(playlist);
|
||||
}}
|
||||
disabled={isProcessing()}
|
||||
class="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isProcessing() ? 'Verarbeite...' : 'Alle transkribieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{/* Playlists Grid */}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={playlists()}>
|
||||
{(playlist) => (
|
||||
<div
|
||||
class={`${getCategoryColor(playlist.category)} p-6 rounded-lg cursor-pointer hover:opacity-90 transition-opacity`}
|
||||
onClick={() => setSelectedPlaylist(playlist)}
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-2xl">{getCategoryIcon(playlist.category)}</span>
|
||||
<div>
|
||||
<h3 class="font-bold text-lg">{playlist.name}</h3>
|
||||
<p class="text-sm text-gray-400">{playlist.category}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="bg-gray-700 px-2 py-1 rounded text-sm">
|
||||
{playlist.url_count} Videos
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
processPlaylist(playlist);
|
||||
}}
|
||||
disabled={isProcessing()}
|
||||
class="mt-4 w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isProcessing() ? 'Verarbeite...' : 'Alle transkribieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* Selected Playlist Details */}
|
||||
<Show when={selectedPlaylist()}>
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">
|
||||
{selectedPlaylist()!.name} - URLs
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setSelectedPlaylist(null)}
|
||||
class="text-gray-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<For each={selectedPlaylist()!.urls}>
|
||||
{(url, index) => (
|
||||
<div class="flex items-center space-x-2 p-2 bg-gray-700 rounded">
|
||||
<span class="text-gray-400 text-sm">{index() + 1}.</span>
|
||||
<a href={url} target="_blank" class="text-blue-400 hover:underline text-sm truncate flex-1">
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Selected Playlist Details */}
|
||||
<Show when={selectedPlaylist()}>
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">{selectedPlaylist()!.name} - URLs</h2>
|
||||
<button
|
||||
onClick={() => setSelectedPlaylist(null)}
|
||||
class="text-gray-400 hover:text-white"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div class="space-y-2 max-h-96 overflow-y-auto">
|
||||
<For each={selectedPlaylist()!.urls}>
|
||||
{(url, index) => (
|
||||
<div class="flex items-center space-x-2 p-2 bg-gray-700 rounded">
|
||||
<span class="text-gray-400 text-sm">{index() + 1}.</span>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:underline text-sm truncate flex-1"
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{playlists().length === 0 && !isCreating() && (
|
||||
<div class="text-center text-gray-400 py-12">
|
||||
<p class="text-xl mb-4">Keine Playlists vorhanden</p>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Erste Playlist erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{playlists().length === 0 && !isCreating() && (
|
||||
<div class="text-center text-gray-400 py-12">
|
||||
<p class="text-xl mb-4">Keine Playlists vorhanden</p>
|
||||
<button
|
||||
onClick={() => setIsCreating(true)}
|
||||
class="px-6 py-3 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||
>
|
||||
Erste Playlist erstellen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,245 +1,257 @@
|
|||
import { createSignal, onMount, For } from 'solid-js';
|
||||
|
||||
interface Model {
|
||||
name: string;
|
||||
size: string;
|
||||
speed: string;
|
||||
accuracy: string;
|
||||
name: string;
|
||||
size: string;
|
||||
speed: string;
|
||||
accuracy: string;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export default function Settings() {
|
||||
const [models, setModels] = createSignal<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = createSignal('base');
|
||||
const [selectedLanguage, setSelectedLanguage] = createSignal('de');
|
||||
const [maxParallelDownloads, setMaxParallelDownloads] = createSignal(3);
|
||||
const [maxParallelTranscriptions, setMaxParallelTranscriptions] = createSignal(2);
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
const [models, setModels] = createSignal<Model[]>([]);
|
||||
const [selectedModel, setSelectedModel] = createSignal('base');
|
||||
const [selectedLanguage, setSelectedLanguage] = createSignal('de');
|
||||
const [maxParallelDownloads, setMaxParallelDownloads] = createSignal(3);
|
||||
const [maxParallelTranscriptions, setMaxParallelTranscriptions] = createSignal(2);
|
||||
const [isSaving, setIsSaving] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
fetchModels();
|
||||
loadSettings();
|
||||
});
|
||||
onMount(() => {
|
||||
fetchModels();
|
||||
loadSettings();
|
||||
});
|
||||
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/models`);
|
||||
const data = await response.json();
|
||||
setModels(data.models);
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
}
|
||||
};
|
||||
const fetchModels = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/models`);
|
||||
const data = await response.json();
|
||||
setModels(data.models);
|
||||
} catch (error) {
|
||||
console.error('Error fetching models:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadSettings = () => {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('transcriber-settings');
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
setSelectedModel(settings.model || 'base');
|
||||
setSelectedLanguage(settings.language || 'de');
|
||||
setMaxParallelDownloads(settings.maxDownloads || 3);
|
||||
setMaxParallelTranscriptions(settings.maxTranscriptions || 2);
|
||||
}
|
||||
};
|
||||
const loadSettings = () => {
|
||||
// Load from localStorage
|
||||
const saved = localStorage.getItem('transcriber-settings');
|
||||
if (saved) {
|
||||
const settings = JSON.parse(saved);
|
||||
setSelectedModel(settings.model || 'base');
|
||||
setSelectedLanguage(settings.language || 'de');
|
||||
setMaxParallelDownloads(settings.maxDownloads || 3);
|
||||
setMaxParallelTranscriptions(settings.maxTranscriptions || 2);
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
setIsSaving(true);
|
||||
const settings = {
|
||||
model: selectedModel(),
|
||||
language: selectedLanguage(),
|
||||
maxDownloads: maxParallelDownloads(),
|
||||
maxTranscriptions: maxParallelTranscriptions()
|
||||
};
|
||||
|
||||
localStorage.setItem('transcriber-settings', JSON.stringify(settings));
|
||||
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
alert('Einstellungen gespeichert!');
|
||||
}, 500);
|
||||
};
|
||||
const saveSettings = () => {
|
||||
setIsSaving(true);
|
||||
const settings = {
|
||||
model: selectedModel(),
|
||||
language: selectedLanguage(),
|
||||
maxDownloads: maxParallelDownloads(),
|
||||
maxTranscriptions: maxParallelTranscriptions(),
|
||||
};
|
||||
|
||||
const getModelColor = (name: string) => {
|
||||
switch(name) {
|
||||
case 'tiny': return 'text-green-400';
|
||||
case 'base': return 'text-blue-400';
|
||||
case 'small': return 'text-yellow-400';
|
||||
case 'medium': return 'text-orange-400';
|
||||
case 'large': return 'text-red-400';
|
||||
default: return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
localStorage.setItem('transcriber-settings', JSON.stringify(settings));
|
||||
|
||||
return (
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Einstellungen</h1>
|
||||
setTimeout(() => {
|
||||
setIsSaving(false);
|
||||
alert('Einstellungen gespeichert!');
|
||||
}, 500);
|
||||
};
|
||||
|
||||
{/* Model Selection */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Whisper Modell</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={models()}>
|
||||
{(model) => (
|
||||
<div
|
||||
class={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
selectedModel() === model.name
|
||||
? 'border-blue-500 bg-blue-900/30'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedModel(model.name)}
|
||||
>
|
||||
<h3 class={`font-bold text-lg mb-2 ${getModelColor(model.name)}`}>
|
||||
{model.name.toUpperCase()}
|
||||
</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Größe:</span>
|
||||
<span class="text-white">{model.size}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Speed:</span>
|
||||
<span class="text-white">{model.speed}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Genauigkeit:</span>
|
||||
<span class="text-white">{model.accuracy}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
const getModelColor = (name: string) => {
|
||||
switch (name) {
|
||||
case 'tiny':
|
||||
return 'text-green-400';
|
||||
case 'base':
|
||||
return 'text-blue-400';
|
||||
case 'small':
|
||||
return 'text-yellow-400';
|
||||
case 'medium':
|
||||
return 'text-orange-400';
|
||||
case 'large':
|
||||
return 'text-red-400';
|
||||
default:
|
||||
return 'text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
{/* Language Selection */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Sprache</h2>
|
||||
<select
|
||||
value={selectedLanguage()}
|
||||
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
</select>
|
||||
</div>
|
||||
return (
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-bold mb-6">Einstellungen</h1>
|
||||
|
||||
{/* Parallel Processing Settings */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Parallel-Verarbeitung</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Max. parallele Downloads: {maxParallelDownloads()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
value={maxParallelDownloads()}
|
||||
onInput={(e) => setMaxParallelDownloads(parseInt(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1 (Langsam)</span>
|
||||
<span>3 (Standard)</span>
|
||||
<span>5 (Schnell)</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Model Selection */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Whisper Modell</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<For each={models()}>
|
||||
{(model) => (
|
||||
<div
|
||||
class={`p-4 rounded-lg border-2 cursor-pointer transition-all ${
|
||||
selectedModel() === model.name
|
||||
? 'border-blue-500 bg-blue-900/30'
|
||||
: 'border-gray-700 hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedModel(model.name)}
|
||||
>
|
||||
<h3 class={`font-bold text-lg mb-2 ${getModelColor(model.name)}`}>
|
||||
{model.name.toUpperCase()}
|
||||
</h3>
|
||||
<div class="space-y-1 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Größe:</span>
|
||||
<span class="text-white">{model.size}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Speed:</span>
|
||||
<span class="text-white">{model.speed}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-gray-400">Genauigkeit:</span>
|
||||
<span class="text-white">{model.accuracy}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Max. parallele Transkriptionen: {maxParallelTranscriptions()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
value={maxParallelTranscriptions()}
|
||||
onInput={(e) => setMaxParallelTranscriptions(parseInt(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1 (Wenig RAM)</span>
|
||||
<span>2 (Standard)</span>
|
||||
<span>4 (Viel RAM)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Language Selection */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Sprache</h2>
|
||||
<select
|
||||
value={selectedLanguage()}
|
||||
onChange={(e) => setSelectedLanguage(e.currentTarget.value)}
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Performance Tips */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">⚡ Performance-Tipps</h2>
|
||||
<ul class="space-y-2 text-sm text-gray-300">
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span><strong>Tiny:</strong> Perfekt für schnelle Tests und Previews</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span><strong>Base/Small:</strong> Guter Kompromiss für die meisten Videos</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span><strong>Large:</strong> Beste Qualität für wichtige Transkriptionen</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>Mehr parallele Downloads = Schneller, aber mehr Bandbreite</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>Mehr parallele Transkriptionen = Schneller, aber mehr RAM-Verbrauch</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{/* Parallel Processing Settings */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Parallel-Verarbeitung</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Max. parallele Downloads: {maxParallelDownloads()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
value={maxParallelDownloads()}
|
||||
onInput={(e) => setMaxParallelDownloads(parseInt(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1 (Langsam)</span>
|
||||
<span>3 (Standard)</span>
|
||||
<span>5 (Schnell)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={isSaving()}
|
||||
class="px-8 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-semibold"
|
||||
>
|
||||
{isSaving() ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-400 mb-2">
|
||||
Max. parallele Transkriptionen: {maxParallelTranscriptions()}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
value={maxParallelTranscriptions()}
|
||||
onInput={(e) => setMaxParallelTranscriptions(parseInt(e.currentTarget.value))}
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex justify-between text-xs text-gray-500 mt-1">
|
||||
<span>1 (Wenig RAM)</span>
|
||||
<span>2 (Standard)</span>
|
||||
<span>4 (Viel RAM)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">System-Info</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">API Server:</span>
|
||||
<span class="ml-2 text-green-400">Online</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Version:</span>
|
||||
<span class="ml-2 text-white">4.0 Parallel</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Platform:</span>
|
||||
<span class="ml-2 text-white">macOS (Apple Silicon)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">API Endpoint:</span>
|
||||
<span class="ml-2 text-white">{API_URL}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
{/* Performance Tips */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">⚡ Performance-Tipps</h2>
|
||||
<ul class="space-y-2 text-sm text-gray-300">
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Tiny:</strong> Perfekt für schnelle Tests und Previews
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Base/Small:</strong> Guter Kompromiss für die meisten Videos
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>
|
||||
<strong>Large:</strong> Beste Qualität für wichtige Transkriptionen
|
||||
</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>Mehr parallele Downloads = Schneller, aber mehr Bandbreite</span>
|
||||
</li>
|
||||
<li class="flex items-start">
|
||||
<span class="mr-2">•</span>
|
||||
<span>Mehr parallele Transkriptionen = Schneller, aber mehr RAM-Verbrauch</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Save Button */}
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
onClick={saveSettings}
|
||||
disabled={isSaving()}
|
||||
class="px-8 py-3 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 font-semibold"
|
||||
>
|
||||
{isSaving() ? 'Speichere...' : 'Einstellungen speichern'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* System Info */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">System-Info</h2>
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-400">API Server:</span>
|
||||
<span class="ml-2 text-green-400">Online</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Version:</span>
|
||||
<span class="ml-2 text-white">4.0 Parallel</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">Platform:</span>
|
||||
<span class="ml-2 text-white">macOS (Apple Silicon)</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-400">API Endpoint:</span>
|
||||
<span class="ml-2 text-white">{API_URL}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,238 +1,235 @@
|
|||
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
|
||||
|
||||
interface Transcript {
|
||||
playlist: string;
|
||||
channel: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
playlist: string;
|
||||
channel: string;
|
||||
filename: string;
|
||||
path: string;
|
||||
size: number;
|
||||
modified: string;
|
||||
}
|
||||
|
||||
const API_URL = 'http://localhost:8000';
|
||||
|
||||
export default function TranscriptViewer() {
|
||||
const [transcripts, setTranscripts] = createSignal<Transcript[]>([]);
|
||||
const [selectedTranscript, setSelectedTranscript] = createSignal<Transcript | null>(null);
|
||||
const [transcriptContent, setTranscriptContent] = createSignal<string>('');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [filteredTranscripts, setFilteredTranscripts] = createSignal<Transcript[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [transcripts, setTranscripts] = createSignal<Transcript[]>([]);
|
||||
const [selectedTranscript, setSelectedTranscript] = createSignal<Transcript | null>(null);
|
||||
const [transcriptContent, setTranscriptContent] = createSignal<string>('');
|
||||
const [searchQuery, setSearchQuery] = createSignal('');
|
||||
const [filteredTranscripts, setFilteredTranscripts] = createSignal<Transcript[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
|
||||
onMount(() => {
|
||||
fetchTranscripts();
|
||||
});
|
||||
onMount(() => {
|
||||
fetchTranscripts();
|
||||
});
|
||||
|
||||
createEffect(() => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (query) {
|
||||
setFilteredTranscripts(
|
||||
transcripts().filter(t =>
|
||||
t.filename.toLowerCase().includes(query) ||
|
||||
t.channel.toLowerCase().includes(query) ||
|
||||
t.playlist.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setFilteredTranscripts(transcripts());
|
||||
}
|
||||
});
|
||||
createEffect(() => {
|
||||
const query = searchQuery().toLowerCase();
|
||||
if (query) {
|
||||
setFilteredTranscripts(
|
||||
transcripts().filter(
|
||||
(t) =>
|
||||
t.filename.toLowerCase().includes(query) ||
|
||||
t.channel.toLowerCase().includes(query) ||
|
||||
t.playlist.toLowerCase().includes(query)
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setFilteredTranscripts(transcripts());
|
||||
}
|
||||
});
|
||||
|
||||
const fetchTranscripts = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcripts`);
|
||||
const data = await response.json();
|
||||
setTranscripts(data);
|
||||
setFilteredTranscripts(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching transcripts:', error);
|
||||
}
|
||||
};
|
||||
const fetchTranscripts = async () => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcripts`);
|
||||
const data = await response.json();
|
||||
setTranscripts(data);
|
||||
setFilteredTranscripts(data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching transcripts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const loadTranscript = async (transcript: Transcript) => {
|
||||
setIsLoading(true);
|
||||
setSelectedTranscript(transcript);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcript/${transcript.path}`);
|
||||
const content = await response.text();
|
||||
setTranscriptContent(content);
|
||||
} catch (error) {
|
||||
console.error('Error loading transcript:', error);
|
||||
setTranscriptContent('Fehler beim Laden des Transkripts');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
const loadTranscript = async (transcript: Transcript) => {
|
||||
setIsLoading(true);
|
||||
setSelectedTranscript(transcript);
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/api/transcript/${transcript.path}`);
|
||||
const content = await response.text();
|
||||
setTranscriptContent(content);
|
||||
} catch (error) {
|
||||
console.error('Error loading transcript:', error);
|
||||
setTranscriptContent('Fehler beim Laden des Transkripts');
|
||||
}
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const downloadTranscript = (transcript: Transcript) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = `${API_URL}/api/transcript/${transcript.path}`;
|
||||
link.download = transcript.filename;
|
||||
link.click();
|
||||
};
|
||||
const downloadTranscript = (transcript: Transcript) => {
|
||||
const link = document.createElement('a');
|
||||
link.href = `${API_URL}/api/transcript/${transcript.path}`;
|
||||
link.download = transcript.filename;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const getPlaylistIcon = (playlist: string) => {
|
||||
if (playlist.includes('tech')) return '💻';
|
||||
if (playlist.includes('people')) return '👥';
|
||||
if (playlist.includes('musik')) return '🎵';
|
||||
if (playlist.includes('gaming')) return '🎮';
|
||||
return '📁';
|
||||
};
|
||||
const getPlaylistIcon = (playlist: string) => {
|
||||
if (playlist.includes('tech')) return '💻';
|
||||
if (playlist.includes('people')) return '👥';
|
||||
if (playlist.includes('musik')) return '🎵';
|
||||
if (playlist.includes('gaming')) return '🎮';
|
||||
return '📁';
|
||||
};
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder="Transkripte durchsuchen..."
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Search Bar */}
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder="Transkripte durchsuchen..."
|
||||
class="w-full px-4 py-2 bg-gray-700 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Transcript List */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">
|
||||
Transkripte ({filteredTranscripts().length})
|
||||
</h2>
|
||||
<div class="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
<For each={filteredTranscripts()}>
|
||||
{(transcript) => (
|
||||
<div
|
||||
class={`p-4 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedTranscript()?.path === transcript.path
|
||||
? 'bg-blue-900'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => loadTranscript(transcript)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-lg">{getPlaylistIcon(transcript.playlist)}</span>
|
||||
<span class="text-sm text-gray-400">{transcript.playlist}</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm mb-1 line-clamp-2">
|
||||
{transcript.filename.replace(/_/g, ' ').replace('.txt', '')}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4 text-xs text-gray-400">
|
||||
<span>{transcript.channel}</span>
|
||||
<span>{formatFileSize(transcript.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadTranscript(transcript);
|
||||
}}
|
||||
class="ml-2 p-2 text-gray-400 hover:text-white"
|
||||
title="Download"
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">
|
||||
{formatDate(transcript.modified)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{filteredTranscripts().length === 0 && (
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
{searchQuery() ? 'Keine Transkripte gefunden' : 'Noch keine Transkripte vorhanden'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Transcript List */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<h2 class="text-xl font-bold mb-4">Transkripte ({filteredTranscripts().length})</h2>
|
||||
<div class="space-y-2 max-h-[600px] overflow-y-auto">
|
||||
<For each={filteredTranscripts()}>
|
||||
{(transcript) => (
|
||||
<div
|
||||
class={`p-4 rounded-lg cursor-pointer transition-colors ${
|
||||
selectedTranscript()?.path === transcript.path
|
||||
? 'bg-blue-900'
|
||||
: 'bg-gray-700 hover:bg-gray-600'
|
||||
}`}
|
||||
onClick={() => loadTranscript(transcript)}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center space-x-2 mb-1">
|
||||
<span class="text-lg">{getPlaylistIcon(transcript.playlist)}</span>
|
||||
<span class="text-sm text-gray-400">{transcript.playlist}</span>
|
||||
</div>
|
||||
<h3 class="font-semibold text-sm mb-1 line-clamp-2">
|
||||
{transcript.filename.replace(/_/g, ' ').replace('.txt', '')}
|
||||
</h3>
|
||||
<div class="flex items-center space-x-4 text-xs text-gray-400">
|
||||
<span>{transcript.channel}</span>
|
||||
<span>{formatFileSize(transcript.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
downloadTranscript(transcript);
|
||||
}}
|
||||
class="ml-2 p-2 text-gray-400 hover:text-white"
|
||||
title="Download"
|
||||
>
|
||||
⬇️
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 mt-2">{formatDate(transcript.modified)}</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
{filteredTranscripts().length === 0 && (
|
||||
<div class="text-center text-gray-400 py-8">
|
||||
{searchQuery() ? 'Keine Transkripte gefunden' : 'Noch keine Transkripte vorhanden'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Transcript Content Viewer */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<Show when={selectedTranscript()}>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Transkript-Inhalt</h2>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(transcriptContent());
|
||||
alert('In Zwischenablage kopiert!');
|
||||
}}
|
||||
class="px-3 py-1 bg-gray-700 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadTranscript(selectedTranscript()!)}
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
⬇️ Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={isLoading()}>
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<p class="mt-4 text-gray-400">Lade Transkript...</p>
|
||||
</div>
|
||||
</Show>
|
||||
{/* Transcript Content Viewer */}
|
||||
<div class="bg-gray-800 p-6 rounded-lg">
|
||||
<Show when={selectedTranscript()}>
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h2 class="text-xl font-bold">Transkript-Inhalt</h2>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(transcriptContent());
|
||||
alert('In Zwischenablage kopiert!');
|
||||
}}
|
||||
class="px-3 py-1 bg-gray-700 text-white rounded hover:bg-gray-600"
|
||||
>
|
||||
📋 Kopieren
|
||||
</button>
|
||||
<button
|
||||
onClick={() => downloadTranscript(selectedTranscript()!)}
|
||||
class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
⬇️ Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!isLoading() && selectedTranscript()}>
|
||||
<div class="bg-gray-900 p-4 rounded-lg max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{transcriptContent()}
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={isLoading()}>
|
||||
<div class="text-center py-12">
|
||||
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
|
||||
<p class="mt-4 text-gray-400">Lade Transkript...</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={!selectedTranscript() && !isLoading()}>
|
||||
<div class="text-center text-gray-400 py-12">
|
||||
<p class="text-xl mb-2">Kein Transkript ausgewählt</p>
|
||||
<p class="text-sm">Wähle ein Transkript aus der Liste aus</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={!isLoading() && selectedTranscript()}>
|
||||
<div class="bg-gray-900 p-4 rounded-lg max-h-[500px] overflow-y-auto">
|
||||
<pre class="text-sm text-gray-300 whitespace-pre-wrap font-mono">
|
||||
{transcriptContent()}
|
||||
</pre>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* Statistics */}
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">{transcripts().length}</div>
|
||||
<div class="text-sm text-gray-400">Gesamt</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{formatFileSize(transcripts().reduce((sum, t) => sum + t.size, 0))}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">Speicher</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{[...new Set(transcripts().map(t => t.channel))].length}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">Kanäle</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Show when={!selectedTranscript() && !isLoading()}>
|
||||
<div class="text-center text-gray-400 py-12">
|
||||
<p class="text-xl mb-2">Kein Transkript ausgewählt</p>
|
||||
<p class="text-sm">Wähle ein Transkript aus der Liste aus</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics */}
|
||||
<div class="bg-gray-800 p-4 rounded-lg">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">{transcripts().length}</div>
|
||||
<div class="text-sm text-gray-400">Gesamt</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{formatFileSize(transcripts().reduce((sum, t) => sum + t.size, 0))}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">Speicher</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-white">
|
||||
{[...new Set(transcripts().map((t) => t.channel))].length}
|
||||
</div>
|
||||
<div class="text-sm text-gray-400">Kanäle</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +1,144 @@
|
|||
---
|
||||
export interface Props {
|
||||
name: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
bio: string;
|
||||
imageUrl?: string;
|
||||
website?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
name: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
bio: string;
|
||||
imageUrl?: string;
|
||||
website?: string;
|
||||
twitter?: string;
|
||||
linkedin?: string;
|
||||
}
|
||||
|
||||
const { name, title, company, bio, imageUrl, website, twitter, linkedin } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10"></div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div class="grid md:grid-cols-3 gap-8 items-center">
|
||||
<!-- Profile Image -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="relative">
|
||||
{imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
class="w-48 h-48 md:w-64 md:h-64 rounded-full mx-auto object-cover border-4 border-theme-primary/20 shadow-xl"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-48 h-48 md:w-64 md:h-64 rounded-full mx-auto bg-gradient-to-br from-theme-primary to-theme-secondary flex items-center justify-center">
|
||||
<span class="text-6xl md:text-8xl text-white font-bold">
|
||||
{name.split(' ').map(n => n[0]).join('')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div class="absolute -bottom-2 -right-2 bg-theme-primary text-white rounded-full p-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="md:col-span-2 text-center md:text-left">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-2">
|
||||
{name}
|
||||
</h1>
|
||||
<p class="text-xl text-theme-primary font-medium mb-1">
|
||||
{title}
|
||||
</p>
|
||||
{company && (
|
||||
<p class="text-lg text-theme-text-muted mb-6">
|
||||
{company}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p class="text-theme-text-muted leading-relaxed mb-6 max-w-2xl">
|
||||
{bio}
|
||||
</p>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="flex gap-4 justify-center md:justify-start">
|
||||
{website && (
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="Website"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{twitter && (
|
||||
<a
|
||||
href={`https://twitter.com/${twitter}`}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{linkedin && (
|
||||
<a
|
||||
href={`https://linkedin.com/in/${linkedin}`}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/>
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10"></div>
|
||||
|
||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16">
|
||||
<div class="grid md:grid-cols-3 gap-8 items-center">
|
||||
<!-- Profile Image -->
|
||||
<div class="md:col-span-1">
|
||||
<div class="relative">
|
||||
{
|
||||
imageUrl ? (
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={name}
|
||||
class="w-48 h-48 md:w-64 md:h-64 rounded-full mx-auto object-cover border-4 border-theme-primary/20 shadow-xl"
|
||||
/>
|
||||
) : (
|
||||
<div class="w-48 h-48 md:w-64 md:h-64 rounded-full mx-auto bg-gradient-to-br from-theme-primary to-theme-secondary flex items-center justify-center">
|
||||
<span class="text-6xl md:text-8xl text-white font-bold">
|
||||
{name
|
||||
.split(' ')
|
||||
.map((n) => n[0])
|
||||
.join('')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div class="absolute -bottom-2 -right-2 bg-theme-primary text-white rounded-full p-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Profile Info -->
|
||||
<div class="md:col-span-2 text-center md:text-left">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-2">
|
||||
{name}
|
||||
</h1>
|
||||
<p class="text-xl text-theme-primary font-medium mb-1">
|
||||
{title}
|
||||
</p>
|
||||
{company && <p class="text-lg text-theme-text-muted mb-6">{company}</p>}
|
||||
|
||||
<p class="text-theme-text-muted leading-relaxed mb-6 max-w-2xl">
|
||||
{bio}
|
||||
</p>
|
||||
|
||||
<!-- Social Links -->
|
||||
<div class="flex gap-4 justify-center md:justify-start">
|
||||
{
|
||||
website && (
|
||||
<a
|
||||
href={website}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="Website"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
twitter && (
|
||||
<a
|
||||
href={`https://twitter.com/${twitter}`}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="Twitter"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M23.953 4.57a10 10 0 01-2.825.775 4.958 4.958 0 002.163-2.723c-.951.555-2.005.959-3.127 1.184a4.92 4.92 0 00-8.384 4.482C7.69 8.095 4.067 6.13 1.64 3.162a4.822 4.822 0 00-.666 2.475c0 1.71.87 3.213 2.188 4.096a4.904 4.904 0 01-2.228-.616v.06a4.923 4.923 0 003.946 4.827 4.996 4.996 0 01-2.212.085 4.936 4.936 0 004.604 3.417 9.867 9.867 0 01-6.102 2.105c-.39 0-.779-.023-1.17-.067a13.995 13.995 0 007.557 2.209c9.053 0 13.998-7.496 13.998-13.985 0-.21 0-.42-.015-.63A9.935 9.935 0 0024 4.59z" />
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
{
|
||||
linkedin && (
|
||||
<a
|
||||
href={`https://linkedin.com/in/${linkedin}`}
|
||||
target="_blank"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
aria-label="LinkedIn"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
|
||||
</svg>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,100 +1,156 @@
|
|||
---
|
||||
export interface Props {
|
||||
totalTalks: number;
|
||||
totalDuration: string;
|
||||
totalViews?: string;
|
||||
topTopics: string[];
|
||||
firstTalk?: string;
|
||||
latestTalk?: string;
|
||||
totalTalks: number;
|
||||
totalDuration: string;
|
||||
totalViews?: string;
|
||||
topTopics: string[];
|
||||
firstTalk?: string;
|
||||
latestTalk?: string;
|
||||
}
|
||||
|
||||
const { totalTalks, totalDuration, totalViews, topTopics, firstTalk, latestTalk } = Astro.props;
|
||||
---
|
||||
|
||||
<section class="py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<!-- Total Talks -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Vorträge</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalTalks}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Talks insgesamt</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Duration -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Gesamtdauer</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalDuration}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Stunden Content</div>
|
||||
</div>
|
||||
|
||||
<!-- Views if available -->
|
||||
{totalViews && (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Aufrufe</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalViews}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Gesamtaufrufe</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<!-- Top Topics -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20 col-span-2 md:col-span-1">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Top Themen</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-theme-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{topTopics.map(topic => (
|
||||
<span class="inline-block bg-theme-primary/10 text-theme-primary text-xs px-2 py-1 rounded-full">
|
||||
{topic}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Info -->
|
||||
{(firstTalk || latestTalk) && (
|
||||
<div class="mt-6 bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{firstTalk && (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-theme-primary rounded-full"></div>
|
||||
<div>
|
||||
<span class="text-theme-text-muted text-sm">Erster Talk:</span>
|
||||
<span class="text-theme-text ml-2">{firstTalk}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{latestTalk && (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-theme-secondary rounded-full"></div>
|
||||
<div>
|
||||
<span class="text-theme-text-muted text-sm">Neuester Talk:</span>
|
||||
<span class="text-theme-text ml-2">{latestTalk}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
||||
<!-- Total Talks -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Vorträge</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-theme-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalTalks}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Talks insgesamt</div>
|
||||
</div>
|
||||
|
||||
<!-- Total Duration -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Gesamtdauer</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-theme-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalDuration}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Stunden Content</div>
|
||||
</div>
|
||||
|
||||
<!-- Views if available -->
|
||||
{
|
||||
totalViews && (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Aufrufe</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-theme-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-3xl font-bold text-theme-text">{totalViews}</div>
|
||||
<div class="text-xs text-theme-text-muted mt-1">Gesamtaufrufe</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
<!-- Top Topics -->
|
||||
<div
|
||||
class="bg-theme-card rounded-xl p-6 border border-theme-border/20 col-span-2 md:col-span-1"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-theme-text-muted text-sm">Top Themen</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5 text-theme-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
{
|
||||
topTopics.map((topic) => (
|
||||
<span class="inline-block bg-theme-primary/10 text-theme-primary text-xs px-2 py-1 rounded-full">
|
||||
{topic}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Timeline Info -->
|
||||
{
|
||||
(firstTalk || latestTalk) && (
|
||||
<div class="mt-6 bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
{firstTalk && (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-theme-primary rounded-full" />
|
||||
<div>
|
||||
<span class="text-theme-text-muted text-sm">Erster Talk:</span>
|
||||
<span class="text-theme-text ml-2">{firstTalk}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{latestTalk && (
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-2 h-2 bg-theme-secondary rounded-full" />
|
||||
<div>
|
||||
<span class="text-theme-text-muted text-sm">Neuester Talk:</span>
|
||||
<span class="text-theme-text ml-2">{latestTalk}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import { defineCollection, z } from 'astro:content';
|
||||
|
||||
const talks = defineCollection({
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
speaker: z.string(),
|
||||
date: z.coerce.date(),
|
||||
category: z.enum([
|
||||
'behavioral-economics',
|
||||
'psychology',
|
||||
'technology',
|
||||
'innovation',
|
||||
'marketing',
|
||||
'philosophy',
|
||||
'business',
|
||||
'creativity',
|
||||
'leadership'
|
||||
]),
|
||||
tags: z.array(z.string()),
|
||||
venue: z.string(),
|
||||
duration: z.string(),
|
||||
videoUrl: z.string().url(),
|
||||
thumbnail: z.string().optional(),
|
||||
readingTime: z.number(),
|
||||
featured: z.boolean().default(false),
|
||||
summary: z.string(),
|
||||
}),
|
||||
type: 'content',
|
||||
schema: z.object({
|
||||
title: z.string(),
|
||||
speaker: z.string(),
|
||||
date: z.coerce.date(),
|
||||
category: z.enum([
|
||||
'behavioral-economics',
|
||||
'psychology',
|
||||
'technology',
|
||||
'innovation',
|
||||
'marketing',
|
||||
'philosophy',
|
||||
'business',
|
||||
'creativity',
|
||||
'leadership',
|
||||
]),
|
||||
tags: z.array(z.string()),
|
||||
venue: z.string(),
|
||||
duration: z.string(),
|
||||
videoUrl: z.string().url(),
|
||||
thumbnail: z.string().optional(),
|
||||
readingTime: z.number(),
|
||||
featured: z.boolean().default(false),
|
||||
summary: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const collections = { talks };
|
||||
export const collections = { talks };
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
---
|
||||
title: "Perspective is Everything: The Psychology of Reframing"
|
||||
speaker: "Rory Sutherland"
|
||||
speakerId: "rory-sutherland"
|
||||
title: 'Perspective is Everything: The Psychology of Reframing'
|
||||
speaker: 'Rory Sutherland'
|
||||
speakerId: 'rory-sutherland'
|
||||
date: 2025-09-08
|
||||
category: "behavioral-economics"
|
||||
tags: ["psychology", "marketing", "economics", "decision-making", "perception"]
|
||||
venue: "TED"
|
||||
duration: "12:16"
|
||||
videoUrl: "https://www.youtube.com/watch?v=iueVZJVEmEs"
|
||||
thumbnail: "/images/talks/rory-perspective.jpg"
|
||||
category: 'behavioral-economics'
|
||||
tags: ['psychology', 'marketing', 'economics', 'decision-making', 'perception']
|
||||
venue: 'TED'
|
||||
duration: '12:16'
|
||||
videoUrl: 'https://www.youtube.com/watch?v=iueVZJVEmEs'
|
||||
thumbnail: '/images/talks/rory-perspective.jpg'
|
||||
readingTime: 8
|
||||
featured: true
|
||||
summary: "Rory Sutherland reveals how reframing our perception can dramatically change our experience of reality, arguing that psychological value is as important as material value in economics and life."
|
||||
summary: 'Rory Sutherland reveals how reframing our perception can dramatically change our experience of reality, arguing that psychological value is as important as material value in economics and life.'
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
|
@ -21,20 +21,25 @@ In this thought-provoking TED talk, advertising legend Rory Sutherland demonstra
|
|||
## 🎯 Key Insights
|
||||
|
||||
### 1. The Philosopher's Cigarette Paradox
|
||||
|
||||
> "If you stand and stare out of the window on your own, you're an antisocial, friendless idiot. If you stand and stare out of the window on your own with a cigarette, you're a fucking philosopher."
|
||||
|
||||
The same behavior becomes completely different based on minimal contextual changes. This demonstrates the immense power of reframing in shaping our social perceptions.
|
||||
|
||||
### 2. Control Matters More Than Circumstances
|
||||
|
||||
Drawing from psychological experiments with dogs and electric shocks, Sutherland shows that having control over our circumstances matters more to our wellbeing than the actual circumstances themselves. This explains why retirees are happier than the unemployed despite being in objectively similar situations.
|
||||
|
||||
### 3. The Eurostar Problem
|
||||
|
||||
For 0.01% of the £6 billion spent reducing Paris-London journey time by 40 minutes, Wi-Fi could have been installed, improving the journey experience far more than the time reduction. This illustrates our systematic bias toward engineering solutions over psychological ones.
|
||||
|
||||
### 4. Red Light Countdown Psychology
|
||||
|
||||
Korean traffic lights with countdown timers reduce accidents at red lights (by reducing road rage) but increase them at green lights (drivers accelerate when seeing time running out). This shows how psychological interventions need careful testing and understanding.
|
||||
|
||||
### 5. Google's Success Secret
|
||||
|
||||
Google succeeded not just technologically but psychologically - people trust a dedicated search engine more than a portal that does many things. This "goal dilution" effect shows how perception drives business success.
|
||||
|
||||
## 💡 Memorable Quotes
|
||||
|
|
@ -45,37 +50,46 @@ Google succeeded not just technologically but psychologically - people trust a d
|
|||
|
||||
- **"The nature of a wait is not just dependent on its numerical quality, its duration, but on the level of uncertainty you experience during that wait."**
|
||||
|
||||
- **"If economics isn't behavioral, I don't know what the hell is."** *(quoting Charlie Munger)*
|
||||
- **"If economics isn't behavioral, I don't know what the hell is."** _(quoting Charlie Munger)_
|
||||
|
||||
## 📚 Core Concepts Explained
|
||||
|
||||
### Praxeology: The Study of Human Choice
|
||||
|
||||
Sutherland introduces Ludwig von Mises' concept of praxeology - the study of human choice and decision-making that should precede economics. This Austrian School perspective treats economics as a subset of psychology rather than vice versa.
|
||||
|
||||
### The Restaurant Floor Principle
|
||||
|
||||
Von Mises argued there's no distinction between the value created by cooking food and sweeping the restaurant floor. Both create essential components of the dining experience. Similarly, marketing and perception create real value, not "dubious" value.
|
||||
|
||||
### Perception Leakage
|
||||
|
||||
Our perceptions are interconnected - a clean car feels like it drives better, branded painkillers work better than generics (even in measured pain reduction). This "leakage" means improving perception in one area improves the overall experience.
|
||||
|
||||
## 🎬 Chapter Breakdown
|
||||
|
||||
### [0:00-2:30] The Electronic Cigarette Opening
|
||||
|
||||
Sutherland's humorous introduction using his e-cigarette to demonstrate how the same action (standing alone at a party) transforms from antisocial to philosophical with a simple prop.
|
||||
|
||||
### [2:30-4:45] Retirees vs. Unemployed
|
||||
|
||||
Why perceived choice matters more than objective circumstances, using the contrast between happy retirees and depressed unemployed youth.
|
||||
|
||||
### [4:45-7:00] The Control Experiment
|
||||
|
||||
The famous psychological experiment with dogs demonstrating the crucial importance of perceived control over actual conditions.
|
||||
|
||||
### [7:00-9:30] Engineering vs. Psychology
|
||||
|
||||
The Eurostar example and the systematic bias toward technical solutions over psychological ones in business and policy.
|
||||
|
||||
### [9:30-11:00] Traffic Lights and Waiting
|
||||
|
||||
Korean traffic light innovations and London Underground dot-matrix displays showing how information reduces frustration more than time reduction.
|
||||
|
||||
### [11:00-12:16] Von Mises and Value Creation
|
||||
|
||||
The philosophical conclusion about the equivalence of perceived and "real" value, using restaurant and postal service examples.
|
||||
|
||||
## 🚀 Practical Takeaways
|
||||
|
|
@ -93,7 +107,7 @@ The philosophical conclusion about the equivalence of perceived and "real" value
|
|||
## 🔗 Related Ideas
|
||||
|
||||
- **Behavioral Economics**: Daniel Kahneman's work on cognitive biases
|
||||
- **Choice Architecture**: Nudge theory by Thaler and Sunstein
|
||||
- **Choice Architecture**: Nudge theory by Thaler and Sunstein
|
||||
- **Austrian Economics**: Ludwig von Mises and subjective value theory
|
||||
- **Marketing Psychology**: The role of perception in brand value
|
||||
|
||||
|
|
@ -105,84 +119,84 @@ The philosophical conclusion about the equivalence of perceived and "real" value
|
|||
|
||||
---
|
||||
|
||||
*This talk beautifully illustrates why advertising legend Rory Sutherland is considered one of the most original thinkers in behavioral economics. His ability to blend humor, psychology, and business insights makes complex ideas accessible and actionable.*
|
||||
_This talk beautifully illustrates why advertising legend Rory Sutherland is considered one of the most original thinkers in behavioral economics. His ability to blend humor, psychology, and business insights makes complex ideas accessible and actionable._
|
||||
|
||||
## 📜 Full Transcript
|
||||
|
||||
What you have here is an electronic cigarette. It's something that since it was invented a year or two ago has given me untold happiness. A little bit of it, I think, is the nicotine, but there's something much bigger than that, which is ever since in the UK they banned smoking in public places, I've never enjoyed a drinks party ever again.
|
||||
What you have here is an electronic cigarette. It's something that since it was invented a year or two ago has given me untold happiness. A little bit of it, I think, is the nicotine, but there's something much bigger than that, which is ever since in the UK they banned smoking in public places, I've never enjoyed a drinks party ever again.
|
||||
|
||||
And the reason I only worked out just the other day, which is when you go to a drinks party and you stand up and you hold a glass of red wine and you talk endlessly to people, you don't actually want to spend all the time talking. It's really, really tiring. Sometimes you just want to stand there silently, alone with your thoughts. Sometimes you just want to stand in the corner and stare out of the window.
|
||||
And the reason I only worked out just the other day, which is when you go to a drinks party and you stand up and you hold a glass of red wine and you talk endlessly to people, you don't actually want to spend all the time talking. It's really, really tiring. Sometimes you just want to stand there silently, alone with your thoughts. Sometimes you just want to stand in the corner and stare out of the window.
|
||||
|
||||
But the problem is, when you can't smoke, if you stand and stare out of the window on your own, you're an antisocial, friendless idiot. If you stand and stare out of the window on your own with a cigarette, you're a fucking philosopher.
|
||||
But the problem is, when you can't smoke, if you stand and stare out of the window on your own, you're an antisocial, friendless idiot. If you stand and stare out of the window on your own with a cigarette, you're a fucking philosopher.
|
||||
|
||||
So the power of reframing things cannot be overstated. What we have is exactly the same thing, the same activity, but one of them makes you feel great, and the other one, with just a small change of posture, makes you feel terrible.
|
||||
So the power of reframing things cannot be overstated. What we have is exactly the same thing, the same activity, but one of them makes you feel great, and the other one, with just a small change of posture, makes you feel terrible.
|
||||
|
||||
I think one of the problems with classical economics is it's absolutely preoccupied with reality. And reality isn't a particular thing. It's a particularly good guide to human happiness. Why, for example, are pensioners much happier than the young unemployed? Both of them, after all, are in exactly the same state of life. You both have too much time on your hands and not much money. But pensioners are reportedly very, very happy, whereas the unemployed are extraordinarily unhappy and depressed.
|
||||
I think one of the problems with classical economics is it's absolutely preoccupied with reality. And reality isn't a particular thing. It's a particularly good guide to human happiness. Why, for example, are pensioners much happier than the young unemployed? Both of them, after all, are in exactly the same state of life. You both have too much time on your hands and not much money. But pensioners are reportedly very, very happy, whereas the unemployed are extraordinarily unhappy and depressed.
|
||||
|
||||
The reason, I think, is that the pensioners believe they've chosen to be pensioners, whereas the young unemployed feel it's been thrust upon them. In England, the upper middle classes have actually solved this problem perfectly, because they've rebranded unemployment. If you're an upper middle class English person, you call unemployment a year off. And that's because having a son who's unemployed in Manchester is really quite embarrassing. But having a son who's unemployed in Thailand is really viewed as quite an accomplishment.
|
||||
The reason, I think, is that the pensioners believe they've chosen to be pensioners, whereas the young unemployed feel it's been thrust upon them. In England, the upper middle classes have actually solved this problem perfectly, because they've rebranded unemployment. If you're an upper middle class English person, you call unemployment a year off. And that's because having a son who's unemployed in Manchester is really quite embarrassing. But having a son who's unemployed in Thailand is really viewed as quite an accomplishment.
|
||||
|
||||
But actually, the power to rebrand things, to understand that actually our experiences, costs, things, don't actually much depend on what they really are, but on how we view them, I genuinely think can't be overstated.
|
||||
But actually, the power to rebrand things, to understand that actually our experiences, costs, things, don't actually much depend on what they really are, but on how we view them, I genuinely think can't be overstated.
|
||||
|
||||
There's an experiment I think Daniel Pink refers to, where you put two dogs in a box, and the box has an electric floor. Every now and then, an electric shock is applied to the floor, which pains the dogs. The only difference is one of the dogs has a small button in its half of the box, and when it nuzzles the button, the electric shock stops. The other dog doesn't have the button. It's exposed to exactly the same level of pain as the dog in the first box, but it has no control over the circumstances. Generally, the first dog can be relatively content. The second dog lapses into complete depression.
|
||||
There's an experiment I think Daniel Pink refers to, where you put two dogs in a box, and the box has an electric floor. Every now and then, an electric shock is applied to the floor, which pains the dogs. The only difference is one of the dogs has a small button in its half of the box, and when it nuzzles the button, the electric shock stops. The other dog doesn't have the button. It's exposed to exactly the same level of pain as the dog in the first box, but it has no control over the circumstances. Generally, the first dog can be relatively content. The second dog lapses into complete depression.
|
||||
|
||||
The circumstances of our lives may actually matter less to our happiness than the sense of control we feel over our lives. It's an interesting question. We asked the question, the whole debate in the Western world is about the level of taxation. But I think there's another debate to be asked, which is the level of control we have over our tax money. That what cost us 10 pounds in one context can be a curse. What cost us 10 pounds in another context can be a curse in a different context we may actually welcome.
|
||||
The circumstances of our lives may actually matter less to our happiness than the sense of control we feel over our lives. It's an interesting question. We asked the question, the whole debate in the Western world is about the level of taxation. But I think there's another debate to be asked, which is the level of control we have over our tax money. That what cost us 10 pounds in one context can be a curse. What cost us 10 pounds in another context can be a curse in a different context we may actually welcome.
|
||||
|
||||
You know, pay 20,000 pounds in tax towards health, and you're merely feeling a mug. Pay 20,000 pounds to endow a hospital ward, and you're called a philanthropist. I'm probably in the wrong country to talk about willingness to pay tax. So, I'll give you one in return.
|
||||
You know, pay 20,000 pounds in tax towards health, and you're merely feeling a mug. Pay 20,000 pounds to endow a hospital ward, and you're called a philanthropist. I'm probably in the wrong country to talk about willingness to pay tax. So, I'll give you one in return.
|
||||
|
||||
How you frame things really matters. Do you call it the bailout of Greece or the bailout of a load of students? Or the stupid banks which lent to Greece? Because they are actually the same thing. What you call them actually affects how you react to them, viscerally and morally.
|
||||
How you frame things really matters. Do you call it the bailout of Greece or the bailout of a load of students? Or the stupid banks which lent to Greece? Because they are actually the same thing. What you call them actually affects how you react to them, viscerally and morally.
|
||||
|
||||
I think psychological value is great, to be absolutely honest. One of my great friends, a professor called Nick Chater, who is the professor of decision sciences in London, believes that we should spend far less time looking into humanity's hidden depths and spend much more time exploring the hidden shallows. I think that's true, actually. I think impressions have an insane effect on what we think and what we do.
|
||||
I think psychological value is great, to be absolutely honest. One of my great friends, a professor called Nick Chater, who is the professor of decision sciences in London, believes that we should spend far less time looking into humanity's hidden depths and spend much more time exploring the hidden shallows. I think that's true, actually. I think impressions have an insane effect on what we think and what we do.
|
||||
|
||||
But what we don't have is a really good model of human psychology, at least pre-Kahneman, perhaps. We didn't have a really good model of human psychology to put alongside models of engineering, of neoclassical economics. So, people who believed in psychological solutions didn't have a model, we didn't have a framework. This is what Warren Buffett's business partner Charlie Munger calls a latticework on which to hang your ideas.
|
||||
But what we don't have is a really good model of human psychology, at least pre-Kahneman, perhaps. We didn't have a really good model of human psychology to put alongside models of engineering, of neoclassical economics. So, people who believed in psychological solutions didn't have a model, we didn't have a framework. This is what Warren Buffett's business partner Charlie Munger calls a latticework on which to hang your ideas.
|
||||
|
||||
Engineers, economists, classical economists all had a very, very robust existing latticework on which practically every idea could be hung. We merely had a collection of random individual insights without an overall model. And what that means is that in looking at solutions, we've probably given too much priority to what I call technical engineering solutions, Newtonian solutions, and not nearly enough to the psychological ones.
|
||||
Engineers, economists, classical economists all had a very, very robust existing latticework on which practically every idea could be hung. We merely had a collection of random individual insights without an overall model. And what that means is that in looking at solutions, we've probably given too much priority to what I call technical engineering solutions, Newtonian solutions, and not nearly enough to the psychological ones.
|
||||
|
||||
You know my example of the Eurostar. Six million pounds spent to reduce the journey time between Paris and London by about 40 minutes. For 0.01% of this money, you could have put Wi-Fi on the trains, which wouldn't have reduced the duration of the journey but would have improved its enjoyment and its usefulness far more. For maybe 10% of the money, you could have paid all of the world's top male and female supermodels to walk up and down the train, handing out free Chateau Petrusse to all the passengers. You'd still have 5 billion pounds in change and people would ask for the trains to be slowed down.
|
||||
You know my example of the Eurostar. Six million pounds spent to reduce the journey time between Paris and London by about 40 minutes. For 0.01% of this money, you could have put Wi-Fi on the trains, which wouldn't have reduced the duration of the journey but would have improved its enjoyment and its usefulness far more. For maybe 10% of the money, you could have paid all of the world's top male and female supermodels to walk up and down the train, handing out free Chateau Petrusse to all the passengers. You'd still have 5 billion pounds in change and people would ask for the trains to be slowed down.
|
||||
|
||||
Why were we not given the chance to solve that problem psychologically? I think it's because there's an imbalance, an asymmetry, in the way we treat creative, emotionally driven psychological ideas versus the way we treat rational, numerical, spreadsheet driven ideas. If you're a creative person, I think quite rightly, you have to share all your ideas for approval with people much more rational than you. You have to go in and you have to have a cost-benefit analysis, a feasibility study, an ROI study and so forth. And I think that's probably right.
|
||||
Why were we not given the chance to solve that problem psychologically? I think it's because there's an imbalance, an asymmetry, in the way we treat creative, emotionally driven psychological ideas versus the way we treat rational, numerical, spreadsheet driven ideas. If you're a creative person, I think quite rightly, you have to share all your ideas for approval with people much more rational than you. You have to go in and you have to have a cost-benefit analysis, a feasibility study, an ROI study and so forth. And I think that's probably right.
|
||||
|
||||
But this does not apply the other way around. People who have an existing framework, an economic framework, an engineering framework, feel that actually logic is its own answer. What they don't say is, well, the numbers all seem to add up, but before I present this idea, I'll go and show it to some really crazy people to see if they can come up with something better. And so we artificially, I think, prioritise what I'd call mechanistic ideas over psychological ideas.
|
||||
But this does not apply the other way around. People who have an existing framework, an economic framework, an engineering framework, feel that actually logic is its own answer. What they don't say is, well, the numbers all seem to add up, but before I present this idea, I'll go and show it to some really crazy people to see if they can come up with something better. And so we artificially, I think, prioritise what I'd call mechanistic ideas over psychological ideas.
|
||||
|
||||
An example of a great psychological idea, the single best improvement in passenger satisfaction on the London Underground per pound spent, came when they didn't add any extra trains nor change the frequency of the trains, they put dot matrix display boards on the platforms. Because the nature of a wait is not just dependent on its numerical quality, its duration, but on the level of uncertainty you experience during that wait. Waiting seven minutes for a train with a countdown clock is less frustrating and irritating than waiting four minutes, knuckle-biting, going, when's this train going to damn well arrive?
|
||||
An example of a great psychological idea, the single best improvement in passenger satisfaction on the London Underground per pound spent, came when they didn't add any extra trains nor change the frequency of the trains, they put dot matrix display boards on the platforms. Because the nature of a wait is not just dependent on its numerical quality, its duration, but on the level of uncertainty you experience during that wait. Waiting seven minutes for a train with a countdown clock is less frustrating and irritating than waiting four minutes, knuckle-biting, going, when's this train going to damn well arrive?
|
||||
|
||||
Here's a beautiful example of a psychological solution deployed in Korea. Red traffic lights have a countdown delay. It's proven to reduce the accident rate in experiments. Why? Because road range, impatience and general irritation are massively reduced when you can actually see the time you have to wait. In China, not really understanding the principle behind this, they applied the same principle to green traffic lights. Which isn't a great idea. You're 200 yards away, you realise you've got five seconds to go, you floor it. The Koreans very assiduously did test both. The accident rate goes down when you apply this to red traffic lights, it goes up when you apply it to green traffic lights.
|
||||
Here's a beautiful example of a psychological solution deployed in Korea. Red traffic lights have a countdown delay. It's proven to reduce the accident rate in experiments. Why? Because road range, impatience and general irritation are massively reduced when you can actually see the time you have to wait. In China, not really understanding the principle behind this, they applied the same principle to green traffic lights. Which isn't a great idea. You're 200 yards away, you realise you've got five seconds to go, you floor it. The Koreans very assiduously did test both. The accident rate goes down when you apply this to red traffic lights, it goes up when you apply it to green traffic lights.
|
||||
|
||||
This is all I'm asking for, really, in human decision-making, is the consideration of these three things. I'm not asking for the complete primacy of one over the other. I'm merely saying that when you solve problems, you should look at all three of these equally, and you should seek as far as possible to find solutions which sit in the sweet spot in the middle.
|
||||
This is all I'm asking for, really, in human decision-making, is the consideration of these three things. I'm not asking for the complete primacy of one over the other. I'm merely saying that when you solve problems, you should look at all three of these equally, and you should seek as far as possible to find solutions which sit in the sweet spot in the middle.
|
||||
|
||||
If you actually look at a great business, you'll nearly always see all of these three things coming into play. Really, really successful businesses. Google is a great, great technological success, but it's also based on a very good psychological insight. People believe something that only does one thing is better at that thing than something that does that thing and something else. It's an innate thing called gold dilution. A.L.F. Fishback has written a paper about this.
|
||||
If you actually look at a great business, you'll nearly always see all of these three things coming into play. Really, really successful businesses. Google is a great, great technological success, but it's also based on a very good psychological insight. People believe something that only does one thing is better at that thing than something that does that thing and something else. It's an innate thing called gold dilution. A.L.F. Fishback has written a paper about this.
|
||||
|
||||
Everybody else at the time of Google, more or less, was trying to be a portal. Yes, there's a search function, but you also have weather, sports scores, bits of news. Google understood that if you're just a search engine, people assume you're a very, very good search engine. All of you know this, actually, from when you go in to buy a television. And in the shabbier end of the row of flat-screen TVs you can see are these rather despised things called combined TV and DVD players. And we have no knowledge whatsoever of the quality of those things, but we look at a combined TV and DVD player and we go, ugh, it's probably a bit of a crap telly and a bit rubbish as a DVD player. So we walk out of the shops with one of each. Google is as much a psychological success as it is a technological one.
|
||||
Everybody else at the time of Google, more or less, was trying to be a portal. Yes, there's a search function, but you also have weather, sports scores, bits of news. Google understood that if you're just a search engine, people assume you're a very, very good search engine. All of you know this, actually, from when you go in to buy a television. And in the shabbier end of the row of flat-screen TVs you can see are these rather despised things called combined TV and DVD players. And we have no knowledge whatsoever of the quality of those things, but we look at a combined TV and DVD player and we go, ugh, it's probably a bit of a crap telly and a bit rubbish as a DVD player. So we walk out of the shops with one of each. Google is as much a psychological success as it is a technological one.
|
||||
|
||||
I propose that we can use psychology to solve problems that we didn't even realize were problems at all. This is my suggestion for getting people to finish their course of antibiotics. Don't give them 24 white pills. Give them 18 white pills and 6 blue ones. And tell them to take the white pills first and then take the blue ones. It's called chunking the likelihood that people will get to the end is much greater when there is a milestone somewhere in the middle.
|
||||
I propose that we can use psychology to solve problems that we didn't even realize were problems at all. This is my suggestion for getting people to finish their course of antibiotics. Don't give them 24 white pills. Give them 18 white pills and 6 blue ones. And tell them to take the white pills first and then take the blue ones. It's called chunking the likelihood that people will get to the end is much greater when there is a milestone somewhere in the middle.
|
||||
|
||||
One of the great mistakes I think of economics is it fails to understand that what something is, whether it's retirement, unemployment, cost, is a function not only of its amount but also its meaning. This is a toll crossing in Britain. Quite often queues happen at the tolls. Sometimes you get very, very severe queues. You could apply the same principle actually if you like to the security lanes in airports.
|
||||
One of the great mistakes I think of economics is it fails to understand that what something is, whether it's retirement, unemployment, cost, is a function not only of its amount but also its meaning. This is a toll crossing in Britain. Quite often queues happen at the tolls. Sometimes you get very, very severe queues. You could apply the same principle actually if you like to the security lanes in airports.
|
||||
|
||||
What would happen if you could actually pay twice as much money to cross the bridge but go through a lane that's an express lane? It's not an unreasonable thing to do. It's an economically efficient thing to do. Time means more to some people than others. If you're waiting, trying to get to a job interview, you'd patently pay a couple of pounds more to go through the fast lane. If you're on the way to visit your mother-in-law, you'd probably prefer to stay on the left.
|
||||
What would happen if you could actually pay twice as much money to cross the bridge but go through a lane that's an express lane? It's not an unreasonable thing to do. It's an economically efficient thing to do. Time means more to some people than others. If you're waiting, trying to get to a job interview, you'd patently pay a couple of pounds more to go through the fast lane. If you're on the way to visit your mother-in-law, you'd probably prefer to stay on the left.
|
||||
|
||||
The only problem is if you introduce this economically efficient solution, people hate it. Because they think you're deliberately creating delays at the bridge in order to maximise your revenue and why on earth should I pay to subsidise your incompetence? On the other hand, change the frame slightly and create charitable yield management so the extra money you go goes not to the bridge company, it goes to charity, and the mental willingness to pay completely changes. You have a relatively economically efficient solution, but one that actually meets with public approval and even a small degree of affection, rather than being seen as bastardy.
|
||||
The only problem is if you introduce this economically efficient solution, people hate it. Because they think you're deliberately creating delays at the bridge in order to maximise your revenue and why on earth should I pay to subsidise your incompetence? On the other hand, change the frame slightly and create charitable yield management so the extra money you go goes not to the bridge company, it goes to charity, and the mental willingness to pay completely changes. You have a relatively economically efficient solution, but one that actually meets with public approval and even a small degree of affection, rather than being seen as bastardy.
|
||||
|
||||
So where economists make the fundamental mistake is they think that money is money. Actually, my pain experienced in paying five pounds is not just proportionate to the amount, but where I think that money is going. And I think understanding that could revolutionise tax policy, it could revolutionise the public services, it could actually change things quite dramatically.
|
||||
So where economists make the fundamental mistake is they think that money is money. Actually, my pain experienced in paying five pounds is not just proportionate to the amount, but where I think that money is going. And I think understanding that could revolutionise tax policy, it could revolutionise the public services, it could actually change things quite dramatically.
|
||||
|
||||
Here's a guy you all need to study. He's an Austrian school economist who was first active in the first half of the 20th century in Vienna. What was interesting about the Austrian school is they actually grew up alongside Freud. And so they're predominantly interested in psychology. They believed that there was a discipline called praxeology, which is a prior discipline to the study of economics. Praxeology is the study of human choice, action and decision making. I think they're right. I think the danger we have in today's world is we have the study of economics considers itself to be a prior discipline to the study of human psychology. But as Charlie Munger says, if economics isn't behavioural, I don't know what the hell is.
|
||||
Here's a guy you all need to study. He's an Austrian school economist who was first active in the first half of the 20th century in Vienna. What was interesting about the Austrian school is they actually grew up alongside Freud. And so they're predominantly interested in psychology. They believed that there was a discipline called praxeology, which is a prior discipline to the study of economics. Praxeology is the study of human choice, action and decision making. I think they're right. I think the danger we have in today's world is we have the study of economics considers itself to be a prior discipline to the study of human psychology. But as Charlie Munger says, if economics isn't behavioural, I don't know what the hell is.
|
||||
|
||||
Von Mises, interestingly, believes economics is just a subset of psychology. I think he refers to economics as the study of human praxeology under conditions of scarcity. But Von Mises, among many other things, I think uses an analogy which is probably the best justification and explanation for the value of marketing, the value of perceived value, and the fact that we should actually treat it as being absolutely equivalent to any other kind of value.
|
||||
Von Mises, interestingly, believes economics is just a subset of psychology. I think he refers to economics as the study of human praxeology under conditions of scarcity. But Von Mises, among many other things, I think uses an analogy which is probably the best justification and explanation for the value of marketing, the value of perceived value, and the fact that we should actually treat it as being absolutely equivalent to any other kind of value.
|
||||
|
||||
We tend, all of us, even those of us who work in marketing, to think of value in two ways. There's the real value, which is when you make something in a factory or provide a service. And then there's a kind of dubious value, which you create by changing the way people look at things. Von Mises completely rejected this idea and he made this distinction. And he used this following analogy.
|
||||
We tend, all of us, even those of us who work in marketing, to think of value in two ways. There's the real value, which is when you make something in a factory or provide a service. And then there's a kind of dubious value, which you create by changing the way people look at things. Von Mises completely rejected this idea and he made this distinction. And he used this following analogy.
|
||||
|
||||
He said, he referred actually to some strange economists called the French physiocrats, who believed that only true value was what you extracted from the land. So if you were a shepherd or a quarryman or a farmer, you created true value. If, however, you bought some wool from the shepherd and charged a premium for converting it into a hat, you weren't actually creating value, you were exploiting the shepherd.
|
||||
He said, he referred actually to some strange economists called the French physiocrats, who believed that only true value was what you extracted from the land. So if you were a shepherd or a quarryman or a farmer, you created true value. If, however, you bought some wool from the shepherd and charged a premium for converting it into a hat, you weren't actually creating value, you were exploiting the shepherd.
|
||||
|
||||
Now, Von Mises says that modern economists make exactly the same mistake with regard to advertising and marketing. He says, if you run a restaurant, there is no healthy distinction to be made between the value you create by cooking the food and the value you create by sweeping the floor. One of them creates perhaps the primary product, the thing we think we're paying for. The other one creates a context within which we can enjoy and appreciate that product. And the idea that one of them should actually have priority over the other is fundamentally wrong.
|
||||
Now, Von Mises says that modern economists make exactly the same mistake with regard to advertising and marketing. He says, if you run a restaurant, there is no healthy distinction to be made between the value you create by cooking the food and the value you create by sweeping the floor. One of them creates perhaps the primary product, the thing we think we're paying for. The other one creates a context within which we can enjoy and appreciate that product. And the idea that one of them should actually have priority over the other is fundamentally wrong.
|
||||
|
||||
Try this quick thought experiment. Imagine a restaurant that serves Michelin-starred food but actually where the restaurant smells of sewage and there's human feces on the floor. The best thing you can do there to create value is not actually to improve the food still further, it's to get rid of the smell and clean up the floor. And it's vital we understand this.
|
||||
Try this quick thought experiment. Imagine a restaurant that serves Michelin-starred food but actually where the restaurant smells of sewage and there's human feces on the floor. The best thing you can do there to create value is not actually to improve the food still further, it's to get rid of the smell and clean up the floor. And it's vital we understand this.
|
||||
|
||||
If that seems like a sort of strange, abstruse thing, in the UK, the post office had a 98% success rate at delivering first-class mail the next day. They decided this wasn't good enough and they wanted to get it up to 99%. The effort to do that almost broke the organization. If at the same time you'd gone and asked people what percentage of first-class mail arrives the next day, the average answer would have been 50% or the modal answer would have been 50% to 60%.
|
||||
If that seems like a sort of strange, abstruse thing, in the UK, the post office had a 98% success rate at delivering first-class mail the next day. They decided this wasn't good enough and they wanted to get it up to 99%. The effort to do that almost broke the organization. If at the same time you'd gone and asked people what percentage of first-class mail arrives the next day, the average answer would have been 50% or the modal answer would have been 50% to 60%.
|
||||
|
||||
Now if your perception is much worse than your reality, what on earth are you doing trying to change the reality? That's like trying to improve the food in a restaurant that stinks. What you need to do is first of all tell people that 98% of mail gets there the next day, first-class mail. That's pretty good. I would argue in Britain there's a much better frame of reference which is to tell people that more first-class mail arrives the next day in the UK than in Germany. Because generally in Britain if you want to make us happy about something, just tell us we do it better than the Germans.
|
||||
Now if your perception is much worse than your reality, what on earth are you doing trying to change the reality? That's like trying to improve the food in a restaurant that stinks. What you need to do is first of all tell people that 98% of mail gets there the next day, first-class mail. That's pretty good. I would argue in Britain there's a much better frame of reference which is to tell people that more first-class mail arrives the next day in the UK than in Germany. Because generally in Britain if you want to make us happy about something, just tell us we do it better than the Germans.
|
||||
|
||||
Choose your frame of reference and the perceived value and therefore the actual value is completely transformed. It has to be said actually of the Germans that the Germans and the French are doing a brilliant job of creating a united Europe. The only thing they didn't expect is they're uniting Europe through a shared mild hatred of the French and Germans. But I'm British, that's the way we like it.
|
||||
Choose your frame of reference and the perceived value and therefore the actual value is completely transformed. It has to be said actually of the Germans that the Germans and the French are doing a brilliant job of creating a united Europe. The only thing they didn't expect is they're uniting Europe through a shared mild hatred of the French and Germans. But I'm British, that's the way we like it.
|
||||
|
||||
What you'll also notice is that in any case our perception is leaky. We can't tell the difference between the quality of the food and the environment in which we consume it. All of you will have seen this phenomenon. If you have your car washed or valeted, when you drive away your car feels as if it drives better. And the reason for this, unless my car mysteriously is changing the oil and performing work which I'm not paying him for and I'm unaware of, is because perception is in any case leaky.
|
||||
What you'll also notice is that in any case our perception is leaky. We can't tell the difference between the quality of the food and the environment in which we consume it. All of you will have seen this phenomenon. If you have your car washed or valeted, when you drive away your car feels as if it drives better. And the reason for this, unless my car mysteriously is changing the oil and performing work which I'm not paying him for and I'm unaware of, is because perception is in any case leaky.
|
||||
|
||||
Analgesics that are branded are more effective at reducing pain than analgesics that are not branded. I don't just mean through reported pain reduction, actual measured pain reduction. And so perception actually is leaky in any case. So if you do something that's perceptually bad in one respect, you can damage the other.
|
||||
Analgesics that are branded are more effective at reducing pain than analgesics that are not branded. I don't just mean through reported pain reduction, actual measured pain reduction. And so perception actually is leaky in any case. So if you do something that's perceptually bad in one respect, you can damage the other.
|
||||
|
||||
Thank you very much.
|
||||
Thank you very much.
|
||||
|
|
|
|||
|
|
@ -1,41 +1,53 @@
|
|||
---
|
||||
title: "Leaders Eat Last: Why Some Teams Pull Together and Others Don't"
|
||||
speaker: "Simon Sinek"
|
||||
speakerId: "simon-sinek"
|
||||
speaker: 'Simon Sinek'
|
||||
speakerId: 'simon-sinek'
|
||||
date: 2014-06-01
|
||||
category: "leadership"
|
||||
tags: ["leadership", "trust", "teamwork", "organizational-culture", "evolutionary-psychology", "marines"]
|
||||
venue: "Microsoft Research"
|
||||
duration: "58:47"
|
||||
videoUrl: "https://www.youtube.com/watch?v=eP38Cxve5xY"
|
||||
thumbnail: "/images/talks/simon-sinek-leaders-eat-last.jpg"
|
||||
category: 'leadership'
|
||||
tags:
|
||||
[
|
||||
'leadership',
|
||||
'trust',
|
||||
'teamwork',
|
||||
'organizational-culture',
|
||||
'evolutionary-psychology',
|
||||
'marines',
|
||||
]
|
||||
venue: 'Microsoft Research'
|
||||
duration: '58:47'
|
||||
videoUrl: 'https://www.youtube.com/watch?v=eP38Cxve5xY'
|
||||
thumbnail: '/images/talks/simon-sinek-leaders-eat-last.jpg'
|
||||
readingTime: 30
|
||||
featured: true
|
||||
summary: "In diesem ausführlichen Microsoft Research Talk erklärt Simon Sinek die evolutionären und anthropologischen Grundlagen von Leadership. Er zeigt, warum echte Führung nichts mit Rang zu tun hat, sondern mit der bewussten Entscheidung, andere zu beschützen - und warum Marines als letzte essen."
|
||||
summary: 'In diesem ausführlichen Microsoft Research Talk erklärt Simon Sinek die evolutionären und anthropologischen Grundlagen von Leadership. Er zeigt, warum echte Führung nichts mit Rang zu tun hat, sondern mit der bewussten Entscheidung, andere zu beschützen - und warum Marines als letzte essen.'
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
In diesem umfassenden Vortrag bei Microsoft Research legt Simon Sinek die wissenschaftlichen Grundlagen echter Führung dar. Basierend auf evolutionärer Psychologie und Anthropologie erklärt er, warum Menschen **soziale Maschinen** sind, die nur in sicheren Umgebungen ihr volles Potenzial entfalten können.
|
||||
In diesem umfassenden Vortrag bei Microsoft Research legt Simon Sinek die wissenschaftlichen Grundlagen echter Führung dar. Basierend auf evolutionärer Psychologie und Anthropologie erklärt er, warum Menschen **soziale Maschinen** sind, die nur in sicheren Umgebungen ihr volles Potenzial entfalten können.
|
||||
|
||||
Seine zentrale These: **Menschen sind nur so gut wie die Umgebung, in der sie sich befinden.** Gute Menschen können in schlechten Umgebungen schlecht werden, und Menschen, die die Gesellschaft aufgegeben hat, können in den richtigen Umgebungen Außergewöhnliches leisten. **Leadership bedeutet, diese Umgebung zu schaffen.**
|
||||
|
||||
## 🧬 Die evolutionären Grundlagen
|
||||
|
||||
### Warum wir überlebt haben
|
||||
|
||||
> "We weren't necessarily the strongest. We weren't necessarily the fastest. And yet we've done quite well. Look at this remarkable world that we've built."
|
||||
|
||||
**Vor 50.000 Jahren:** Homo sapiens teilte sich die Erde mit anderen Hominiden-Arten. Wir waren nicht die stärksten oder schnellsten, aber wir haben überlebt, weil wir **soziale Tiere** sind.
|
||||
|
||||
**Der Überlebensvorteil:**
|
||||
|
||||
- Wir lebten in Gruppen von maximal 150 Menschen
|
||||
- Vertrauen und Kooperation waren überlebenswichtig
|
||||
- "Ich konnte nachts schlafen und darauf vertrauen, dass jemand aus meinem Stamm nach Gefahren Ausschau hielt"
|
||||
|
||||
### Das moderne Dilemma
|
||||
|
||||
> "When we do not feel safe amongst the people with whom we work, the natural human inclination is cynicism, paranoia, mistrust, and self-interest."
|
||||
|
||||
**Die Paleolithische Gefahr vs. Moderne Gefahr:**
|
||||
|
||||
- **Damals:** Wetter, Ressourcenmangel, Säbelzahntiger
|
||||
- **Heute:** Wirtschaftliche Unsicherheit, Börse, neue Technologien, Konkurrenz
|
||||
|
||||
|
|
@ -44,30 +56,36 @@ Seine zentrale These: **Menschen sind nur so gut wie die Umgebung, in der sie si
|
|||
## 🎯 Die wahre Definition von Leadership
|
||||
|
||||
### Leadership ist eine Wahl, kein Rang
|
||||
|
||||
> "Leadership has nothing to do with rank. It has nothing to do with the title you have on your card. Leadership is a choice."
|
||||
|
||||
**Was Leadership NICHT ist:**
|
||||
|
||||
- Charisma (nützlich, aber nicht essentiell)
|
||||
- Vision (nicht jeder ist ein Visionär)
|
||||
- Strategisches Denken (eine Fähigkeit, kein Muss)
|
||||
- Rang oder Titel
|
||||
|
||||
**Was Leadership IST:**
|
||||
|
||||
- **Mut** - die einzige essentielle Eigenschaft
|
||||
- Die bewusste Entscheidung, für andere da zu sein
|
||||
- Die Bereitschaft, die eigenen Interessen zurückzustellen
|
||||
- Die Wahl, die Person links und rechts von dir zu beschützen
|
||||
|
||||
### Die Hierarchie-Anthropologie
|
||||
|
||||
> "We are naturally hierarchical and we always organize ourselves in hierarchies."
|
||||
|
||||
**Das Alpha-System:**
|
||||
|
||||
- Menschen bewerten sich ständig gegenseitig (Alpha/Beta)
|
||||
- Alpha-Status ist immer relativ zur Gruppe
|
||||
- Wir geben freiwillig Vorteile an unsere "Alphas" ab
|
||||
- **Der Deal:** Bessere Ressourcen gegen Schutz bei Gefahr
|
||||
|
||||
**Warum wir Banking-CEOs hassen:**
|
||||
|
||||
> "It's that we know deep inside us that they have violated the very definition of what it means to be a leader. They have accepted all of the perks and bonuses and benefits of being the leader and yet they're not willing to make any of the sacrifices."
|
||||
|
||||
Sie nehmen die Vorteile, aber verweigern den Schutz.
|
||||
|
|
@ -75,6 +93,7 @@ Sie nehmen die Vorteile, aber verweigern den Schutz.
|
|||
## 🛡️ Wie man eine sichere Umgebung schafft
|
||||
|
||||
### 1. Menschen vor Zahlen
|
||||
|
||||
> "Great leaders would never sacrifice the people to save the numbers. Great leaders would sacrifice the numbers to save the people."
|
||||
|
||||
**Das Problem:** Wenn Unternehmen Menschen entlassen, um Zahlen zu erreichen, fühlen sich die verbleibenden Mitarbeiter unsicher.
|
||||
|
|
@ -82,6 +101,7 @@ Sie nehmen die Vorteile, aber verweigern den Schutz.
|
|||
**Die Lösung:** Wie Familien in schweren Zeiten - "den Gürtel enger schnallen" statt Familienmitglieder zu "entlassen".
|
||||
|
||||
### 2. Radikale Ehrlichkeit
|
||||
|
||||
> "It's really easy to be honest. Just tell the truth. And if you tell the truth on a regular basis, we will say you have integrity."
|
||||
|
||||
**Die Marines-Geschichte:** Ein Marine wird fast aus dem Corps geworfen, nicht weil er beim Wachdienst eingeschlafen ist, sondern weil er gelogen hat.
|
||||
|
|
@ -89,11 +109,13 @@ Sie nehmen die Vorteile, aber verweigern den Schutz.
|
|||
**Das Prinzip:** "Wir glauben, dass du Verantwortung für deine Handlungen übernimmst zum Zeitpunkt, als du sie ausführst, nicht zum Zeitpunkt, als du erwischt wirst."
|
||||
|
||||
**Praktische Ehrlichkeit:**
|
||||
|
||||
- Keine "Sandwich-Kritik" (Gutes - Schlechtes - Gutes)
|
||||
- Direkt sein: "Ich muss ehrlich mit dir sein, deine Leistung war schlecht"
|
||||
- Bei Fehlern ehrlich sein: "Ich war nicht ehrlich zu dir"
|
||||
|
||||
### 3. Anderen erlauben zu scheitern
|
||||
|
||||
**Die David Marquet Geschichte:**
|
||||
Marquet, U-Boot-Kapitän der USS Santa Fe (schlechteste Crew der Navy), revolutionierte das Leadership durch einen einfachen Wandel:
|
||||
|
||||
|
|
@ -107,19 +129,23 @@ Marquet, U-Boot-Kapitän der USS Santa Fe (schlechteste Crew der Navy), revoluti
|
|||
## 🔑 Praktische Leadership-Prinzipien
|
||||
|
||||
### Authority runterdrücken, nicht Information hochbringen
|
||||
|
||||
> "In most organizations, the people at the top have all of the authority, but none of the information. And the people who are actually performing the jobs have all the information, but none of the authority."
|
||||
|
||||
**Die Lösung:** Autorität zu denen bringen, die die Information haben.
|
||||
|
||||
### Training ist zum Scheitern da
|
||||
|
||||
> "In training, metrics are supposed to go down because you want people to try hard and fail and find out where the line is."
|
||||
|
||||
**Die Metapher:** "Du kannst ein Loch in die Schiffsseite über der Wasserlinie schlagen und es reparieren. Aber du machst das immer wieder, damit du kein Loch unter der Wasserlinie schlägst."
|
||||
|
||||
### Leadership ist wie Erziehung
|
||||
|
||||
> "The closest analogy I can give to you about what leadership is is parenting."
|
||||
|
||||
**Was gute Eltern tun:**
|
||||
|
||||
- Opfer bringen für ihre Kinder
|
||||
- Disziplin wenn nötig
|
||||
- Möglichkeiten und Bildung bieten
|
||||
|
|
@ -128,11 +154,13 @@ Marquet, U-Boot-Kapitän der USS Santa Fe (schlechteste Crew der Navy), revoluti
|
|||
**Was gute Leader tun:** Exakt dasselbe für ihre Teams.
|
||||
|
||||
### Das Messbarkeits-Problem
|
||||
|
||||
> "Leadership in parenting the same. You have no idea if you're being a good parent on a daily basis... But you won't actually see a return on your investment for like 30 years."
|
||||
|
||||
**Wie Fitness:** Man sieht keine täglichen Veränderungen, aber nach Monaten sind die Ergebnisse dramatisch.
|
||||
|
||||
**Messbare Indikatoren:**
|
||||
|
||||
- Mitarbeiter-Fluktuation
|
||||
- Durchschnittliche Verweildauer
|
||||
- Loyalität (nicht Geld-abhängig)
|
||||
|
|
@ -140,6 +168,7 @@ Marquet, U-Boot-Kapitän der USS Santa Fe (schlechteste Crew der Navy), revoluti
|
|||
## 🦅 Die Marines-Philosophie
|
||||
|
||||
### "Leaders Eat Last"
|
||||
|
||||
> "If you go to any chow hall anywhere in the world on any marine base, what you will see is they will line up in rank order during chow time. Most junior man eats first. Most senior man eats last."
|
||||
|
||||
**Die Geschichte:** Ein Marine-Offizier aß nicht, weil das Essen ausging, nachdem seine Männer gegessen hatten. Im Feld teilten seine Männer ihr Essen mit ihm.
|
||||
|
|
@ -147,15 +176,18 @@ Marquet, U-Boot-Kapitän der USS Santa Fe (schlechteste Crew der Navy), revoluti
|
|||
**Das Prinzip:** Wenn Leader sich für ihre Leute opfern, opfern sich die Leute für ihre Leader.
|
||||
|
||||
### Leadership als Verantwortung
|
||||
|
||||
> "You will never hear the words, I am a leader... Here are the words they speak. I am a leader of Marines."
|
||||
|
||||
**Der Unterschied:**
|
||||
|
||||
- Nicht: "Ich bin ein Leader"
|
||||
- Sondern: "Ich bin ein Leader von Menschen"
|
||||
|
||||
Das Wort beinhaltet automatisch die Verantwortung für andere.
|
||||
|
||||
### Die Mutter-Metaphor
|
||||
|
||||
> "There's a photograph of a mother lying on top of her child... At the sound of a gun, it's a mother's instinct to throw herself onto her child, potentially risking her own life."
|
||||
|
||||
**Leadership:** Die Bereitschaft, sich bei Gefahr über seine "Kinder" (Team) zu werfen.
|
||||
|
|
@ -163,15 +195,18 @@ Das Wort beinhaltet automatisch die Verantwortung für andere.
|
|||
## 🔄 Organisatorische Transformation
|
||||
|
||||
### Sei der Leader, den du dir wünschst
|
||||
|
||||
> "We cannot sit here with our arms folded and simply complain that our leadership doesn't look after us... We must be the leaders we wish we had."
|
||||
|
||||
**Praktische Schritte:**
|
||||
|
||||
- Finde jemanden, dem du vertraust
|
||||
- Bildet ein Sicherheitsnetz füreinander
|
||||
- Erweitert das Netz auf andere
|
||||
- Schafft eine Bewegung des Füreinander-Sorgns
|
||||
|
||||
### Der Dominoeffekt
|
||||
|
||||
**Wie Diktatoren arbeiten:** Sie säen Paranoia und Trennung, weil sie wissen: Wenn Menschen zusammenkommen, sind sie weg.
|
||||
|
||||
**Wie Veränderung funktioniert:** Nicht durch "Revolution" (Reorgs), sondern durch "Evolution" - ein Baustein nach dem anderen.
|
||||
|
|
@ -179,10 +214,12 @@ Das Wort beinhaltet automatisch die Verantwortung für andere.
|
|||
## 💡 Tiefere Einsichten
|
||||
|
||||
### Das Investitions-vs-Glücksspiel Paradigma
|
||||
|
||||
**General Electric (1980s-1990s):** Rasanter Aufstieg, dramatischer Fall = Glücksspiel
|
||||
**Costco:** Konstantes, langfristiges Wachstum = Investition
|
||||
|
||||
**1 Dollar investiert 1985:**
|
||||
|
||||
- GE: 600% Return (aber nur wenn zum richtigen Zeitpunkt verkauft)
|
||||
- S&P 500: 600% Return
|
||||
- Costco: 1200% Return
|
||||
|
|
@ -190,12 +227,15 @@ Das Wort beinhaltet automatisch die Verantwortung für andere.
|
|||
**Die Lektion:** Langfristig auf Menschen setzen schlägt kurzfristige Zahlen-Fixierung.
|
||||
|
||||
### Die Technologie-Ironie
|
||||
|
||||
> "Companies like Google and Facebook... invest huge sums of time and energy and money to figure out ways to organize their corporate cultures so that people will cooperate better... to produce a product that keeps us apart from each other."
|
||||
|
||||
Sie wissen, was Menschen zum Kooperieren bringt, produzieren aber Technologie, die uns trennt.
|
||||
|
||||
### Die Sicherheits-Bewertung
|
||||
**Wie erkenne ich sichere Teams?**
|
||||
|
||||
**Wie erkenne ich sichere Teams?**
|
||||
|
||||
- Es ist wie Dating - es braucht Zeit
|
||||
- Minimum 6 Monate, um sich zugehörig zu fühlen
|
||||
- Nach 7 Jahren sollte Vertrauen da sein, sonst ist es Zeit zu gehen
|
||||
|
|
@ -213,10 +253,10 @@ Er zeigt, dass Leadership keine mystische Eigenschaft ist, sondern eine erlernba
|
|||
|
||||
## Vollständiges Transkript
|
||||
|
||||
*Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video.*
|
||||
_Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video._
|
||||
|
||||
[Aufgrund der außergewöhnlichen Länge dieses Talks (58 Minuten) ist das vollständige Transkript über 15.000 Wörter lang. Es beginnt mit:]
|
||||
|
||||
Each year Microsoft Research hosts hundreds of influential speakers from around the world, including leading scientists, renowned experts in technology, book authors and leading academics, and makes videos of these lectures freely available. Hello everyone. Thank you for coming. My name is Aaron Greenberg. I'm the Chief of Staff in the Microsoft Devices Group...
|
||||
|
||||
[Das vollständige Transkript würde hier fortgesetzt werden - aus Platzgründen hier gekürzt]
|
||||
[Das vollständige Transkript würde hier fortgesetzt werden - aus Platzgründen hier gekürzt]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,17 @@
|
|||
---
|
||||
title: "Love Your Work"
|
||||
speaker: "Simon Sinek"
|
||||
speakerId: "simon-sinek"
|
||||
title: 'Love Your Work'
|
||||
speaker: 'Simon Sinek'
|
||||
speakerId: 'simon-sinek'
|
||||
date: 2012-10-01
|
||||
category: "leadership"
|
||||
tags: ["career", "passion", "purpose", "work-life-balance", "fulfillment", "workplace-culture"]
|
||||
venue: "Creative Mornings"
|
||||
duration: "42:29"
|
||||
videoUrl: "https://www.youtube.com/watch?v=jDIZS4IQlQk"
|
||||
thumbnail: "/images/talks/simon-sinek-love-work.jpg"
|
||||
category: 'leadership'
|
||||
tags: ['career', 'passion', 'purpose', 'work-life-balance', 'fulfillment', 'workplace-culture']
|
||||
venue: 'Creative Mornings'
|
||||
duration: '42:29'
|
||||
videoUrl: 'https://www.youtube.com/watch?v=jDIZS4IQlQk'
|
||||
thumbnail: '/images/talks/simon-sinek-love-work.jpg'
|
||||
readingTime: 25
|
||||
featured: false
|
||||
summary: "In diesem tiefgreifenden Creative Mornings Talk erklärt Simon Sinek, warum so viele Menschen ihre Arbeit hassen und wie das katastrophale Auswirkungen auf Gesundheit, Beziehungen und sogar auf unsere Kinder hat. Er zeigt am Beispiel der US Marines, wie echte Erfüllung durch das Dienen für andere entsteht."
|
||||
summary: 'In diesem tiefgreifenden Creative Mornings Talk erklärt Simon Sinek, warum so viele Menschen ihre Arbeit hassen und wie das katastrophale Auswirkungen auf Gesundheit, Beziehungen und sogar auf unsere Kinder hat. Er zeigt am Beispiel der US Marines, wie echte Erfüllung durch das Dienen für andere entsteht.'
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
|
@ -23,25 +23,30 @@ Seine zentrale These: **Erfüllung kommt davon, Zeit und Energie für andere zu
|
|||
## 🎯 Die schockierenden Fakten
|
||||
|
||||
### Das Krankenhaus-Problem
|
||||
|
||||
> "250,000 people a year who are dying in our hospitals due to preventable deaths... 5% of hospital administrators are doctors. Most of them are number crunchers."
|
||||
|
||||
**Das Problem:** Obwohl Amerika die besten Ärzte, Technologie und Medikamente der Welt hat, sterben Menschen, weil:
|
||||
|
||||
- Krankenhäuser wie Unternehmen geführt werden (nach Zahlen, nicht nach Menschen)
|
||||
- Das Personal sich nicht umeinander kümmert
|
||||
- Es keine echte Kameradschaft gibt
|
||||
- Menschen sich nicht als Teil von etwas Größerem fühlen
|
||||
|
||||
### Die Auswirkungen auf Familien
|
||||
|
||||
> "Parents who come home from jobs they hate or don't love, their kids are more likely to be bullies at school."
|
||||
|
||||
**Der Teufelskreis:**
|
||||
|
||||
1. Eltern hassen ihre Jobs
|
||||
2. Sie kommen gestresst nach Hause
|
||||
2. Sie kommen gestresst nach Hause
|
||||
3. Ihre Kinder werden zu Bullies
|
||||
4. Diese Kinder leiden unter Depression und Selbstmord
|
||||
5. Das Problem liegt nicht in den Schulen - sondern in den Jobs der Eltern
|
||||
|
||||
### Die Gesundheitskrise
|
||||
|
||||
> "When we're unfulfilled by the work that we do, we focus on the details. And when we focus on the details, we retract from each other. When we retract from each other, we feel lonely. And when we feel lonely, cancer goes up, heart disease goes up, diabetes goes up."
|
||||
|
||||
Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
||||
|
|
@ -49,20 +54,24 @@ Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
|||
## 💡 Die Marine Corps Lösung
|
||||
|
||||
### Was die Marines richtig machen
|
||||
|
||||
> "They're taking a group of strangers, people who don't know each other, who are showing up and within a very, very short period of time, learn to trust each other so much that they would give their lives for each other."
|
||||
|
||||
**Das Geheimnis:** Die Marines verstehen, dass Menschen nicht für abstrakte Konzepte wie "Gott und Vaterland" kämpfen, sondern **für die Person links und rechts von ihnen**.
|
||||
|
||||
### Der Confidence Course
|
||||
|
||||
> "They have another course called the confidence course. And it's never timed. And most of the obstacles on this course cannot be completed by yourself. They must be completed in teams."
|
||||
|
||||
**Die Psychologie:**
|
||||
|
||||
1. **Erste zwei Wochen:** Jeder will beweisen, wie stark er ist (wie bei neuen Jobs)
|
||||
2. **Nach zwei Wochen:** Sie beginnen, sich anzufeuern
|
||||
3. **Organische Entwicklung:** Sie helfen sich gegenseitig
|
||||
4. **Ausgrenzung:** Wer nicht hilft, wird ausgegrenzt, bis er lernt
|
||||
|
||||
### Das Vulnerabilitäts-Prinzip
|
||||
|
||||
> "We have to take the risk to make ourselves vulnerable. Yes, you might do something for someone else and they may not do something back for you. That's the risk you run."
|
||||
|
||||
**Die Regel:** Niemand hilft dir, bis du bereit bist, anderen zu helfen. Du musst das Risiko eingehen, verletzlich zu sein.
|
||||
|
|
@ -70,15 +79,18 @@ Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
|||
## 🧠 Die Wissenschaft der Erfüllung
|
||||
|
||||
### Oxytocin - Das Bindungshormon
|
||||
|
||||
> "When we do good for others and we look out for those in our tribe, we look out for those in our group, it actually feels good. Biologically it releases oxytocin."
|
||||
|
||||
**Der positive Kreislauf:**
|
||||
|
||||
1. Etwas Gutes für andere tun → Oxytocin wird freigesetzt
|
||||
2. Oxytocin macht uns glücklich
|
||||
3. Mehr Oxytocin → Wir wollen mehr Gutes für andere tun
|
||||
4. Andere werden inspiriert, auch Gutes zu tun
|
||||
|
||||
### Das Geld-Problem
|
||||
|
||||
> "Think about the invention of money, right? It used to be like, you go to someone's house, you cook them dinner, and the deal was they'll do the dishes. Time and energy, exchange for time and energy."
|
||||
|
||||
**Der Verlust der Erfüllung:** Wir haben persönliche Zeit und Energie durch Geld ersetzt - IOUs für zukünftige Dienstleistungen. Dadurch entsteht keine echte emotionale Verbindung.
|
||||
|
|
@ -86,26 +98,32 @@ Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
|||
## 🔥 Praktische Führungsstrategien
|
||||
|
||||
### Selbstvertrauen aufbauen
|
||||
|
||||
> "Before anyone is willing to put themselves out for another, they have to have self-confidence, real self-confidence."
|
||||
|
||||
**Das Paradox:** Du musst selbstbewusst sein, um anderen zu helfen. Aber um selbstbewusst zu werden, brauchst du Menschen, die dich unterstützen.
|
||||
|
||||
**Die Rolle des Managements:**
|
||||
|
||||
- Nicht: "Ich brauche euch, mehr mit weniger zu schaffen" (= "Ihr seid nicht gut genug")
|
||||
- Sondern: "Ich brauche euch, mehr mit dem zu schaffen, was ihr habt" (= "Ihr seid fähig")
|
||||
|
||||
### Der Lehrer-Test
|
||||
|
||||
> "Close your eyes and think back to high school. And think of that one teacher who took you under their wing and cared for you... You probably are the person you are today in some part because of that person."
|
||||
|
||||
**Die Macht des Mentoring:**
|
||||
|
||||
- Jeder kann den Namen seines inspirierenden Lehrers nennen
|
||||
- Niemand kann sich an die anderen Lehrer erinnern
|
||||
- **Die Frage:** Möchtest du die Person sein, deren Namen jemand in 30 Jahren nennt?
|
||||
|
||||
### Management vs. Authority
|
||||
|
||||
> "In the military, they give medals to people who are willing to sacrifice themselves so that others may gain. In business, we give bonuses to people who are willing to sacrifice others so that we may gain."
|
||||
|
||||
**Die Verantwortung von Führungskräften:**
|
||||
|
||||
- Nicht: Deadlines durchsetzen
|
||||
- Sondern: Menschen dabei helfen, ihre eigenen Stärken zu erkennen
|
||||
- Sie in Situationen bringen, wo sie scheitern können (aber sie dabei unterstützen)
|
||||
|
|
@ -114,11 +132,13 @@ Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
|||
## 🌱 Kleine Schritte, große Wirkung
|
||||
|
||||
### Die Papier-Geschichte
|
||||
|
||||
> "I was walking down the street two days ago and a guy's backpack was open and a whole bunch of paper fell out... The guy in front of us turns to us and says, I saw you help that guy. That was really cool."
|
||||
|
||||
**Der Multiplikator-Effekt:** Wenn Menschen sehen, wie du anderen hilfst, werden sie inspiriert, dasselbe zu tun.
|
||||
|
||||
### Praktische Mini-Aktionen
|
||||
|
||||
- Halte jemandem die Tür auf
|
||||
- Mache zwei Tassen Kaffee statt einer
|
||||
- Lächle den Barista an
|
||||
|
|
@ -128,6 +148,7 @@ Arbeitsunzufriedenheit tötet uns buchstäblich durch Krankheiten.
|
|||
> "A little time and a little energy. And you'll find around work that people give a little time and a little energy back to you."
|
||||
|
||||
### Die Beziehungs-Evolution
|
||||
|
||||
> "You go for a coffee with someone. Then you go for a two hour coffee. Then you go for a coffee and a lunch... And eventually you get married. It's slow. It takes time."
|
||||
|
||||
Echte Arbeitsbeziehungen entwickeln sich genauso wie romantische Beziehungen - langsam, organisch, über Zeit.
|
||||
|
|
@ -135,6 +156,7 @@ Echte Arbeitsbeziehungen entwickeln sich genauso wie romantische Beziehungen - l
|
|||
## ⚠️ Was nicht funktioniert
|
||||
|
||||
### Der Email-Trick
|
||||
|
||||
**Falsch:** "Hi, haven't seen you in years! Hope you're well... By the way, could you vote for me..."
|
||||
|
||||
**Richtig:** "Hi, I'm hoping you could vote for me... I haven't seen you in years, hope you're well..."
|
||||
|
|
@ -142,11 +164,13 @@ Echte Arbeitsbeziehungen entwickeln sich genauso wie romantische Beziehungen - l
|
|||
**Die Lektion:** Sei ehrlich über deine Absichten. Menschen spüren, wenn du sie nur "buttern" willst.
|
||||
|
||||
### Die Generosität ohne Erwartung
|
||||
|
||||
> "Don't give someone a cup of coffee if you need a favor back. Just ask them for the favor. It builds trust."
|
||||
|
||||
**Das Problem:** Wenn du immer etwas Nettes tust und dann um einen Gefallen bittest, verlieren Menschen das Vertrauen in dich.
|
||||
|
||||
### Mother Teresa Warnung
|
||||
|
||||
> "Mother Teresa, who's the poster child for giving selflessly to all who need at the end of her life started questioning existence of God and by the way hated her life."
|
||||
|
||||
**Die Lektion:** Grenzenlos für alle zu geben ist selbstzerstörerisch. Du musst auswählen, wem du hilfst.
|
||||
|
|
@ -154,16 +178,19 @@ Echte Arbeitsbeziehungen entwickeln sich genauso wie romantische Beziehungen - l
|
|||
## 🏢 Organisatorische Veränderung
|
||||
|
||||
### Das Handy-Verbot
|
||||
|
||||
> "There should be no cell phones in conference rooms, none, zero... Relationships are formed this way. We're waiting for a meeting to start and we go, how's your dad? I heard he was in the hospital."
|
||||
|
||||
**Der Punkt:** Echte Beziehungen entstehen in den kleinen Momenten zwischen den offiziellen Aktivitäten.
|
||||
|
||||
### Der Vertrauensmaßstab
|
||||
|
||||
> "There's only one machine that I found that really accurately measures trust better than any other sort of metric. It's called a human being."
|
||||
|
||||
Du kannst Vertrauen nicht mit KPIs messen - nur Menschen können Vertrauen spüren.
|
||||
|
||||
### Die kritische Masse
|
||||
|
||||
> "When we reach a critical mass in society, it will tip... There was no such thing as massive layoffs as business strategy prior to 1980s."
|
||||
|
||||
**Die Hoffnung:** Genauso wie sich die Unternehmenskultur in den 1980ern zum Schlechteren wendete (Gordon Gekko, Shareholder Value), kann sie sich wieder zum Besseren wenden.
|
||||
|
|
@ -173,11 +200,13 @@ Du kannst Vertrauen nicht mit KPIs messen - nur Menschen können Vertrauen spür
|
|||
> "My ideal is to live in a world in which the vast majority of people wake up every single morning inspired to go to work and fulfill by the work that they do."
|
||||
|
||||
**Sineks Messung seines eigenen Erfolgs:**
|
||||
|
||||
- Nicht: Wie viele Bücher verkauft wurden
|
||||
- Sondern: Amazon-Rankings als Indikator, dass die Idee sich verbreitet
|
||||
- Kein Marketing, keine Werbung - nur die Kraft der Idee selbst
|
||||
|
||||
### Der Dominoeffekt
|
||||
|
||||
Wenn genug Menschen beginnen, füreinander zu sorgen, wird es zur gesellschaftlichen Norm. Wie bei einem Virus breitet sich Güte aus - aber auch Egoismus breitet sich aus. **Wir entscheiden, welches Virus wir verbreiten.**
|
||||
|
||||
---
|
||||
|
|
@ -194,10 +223,10 @@ Der Talk ist ein Aufruf zur persönlichen Verantwortung: Du musst nicht warten,
|
|||
|
||||
## Vollständiges Transkript
|
||||
|
||||
*Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video.*
|
||||
_Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video._
|
||||
|
||||
[Das vollständige Transkript ist sehr lang - über 8000 Wörter. Es beginnt mit:]
|
||||
|
||||
Cheers! Thank you. So here's a little issue we have in America today. There are currently about 250,000 people a year who are dying in our hospitals due to preventable deaths. And I'm not talking about negligence. I'm talking about little accidents. I'm talking about the doctor in the morning, not properly briefing the doctor for the evening. I'm talking about things that we can't sue anybody. There's nothing that we can see that's wrong. But there's 250,000 preventable deaths every year. That's about 27, 47's going down every single week. That's what's the equivalent to. And the confusing thing is that we have the best doctors in the world. We have the most advanced technology in the world...
|
||||
|
||||
[Das vollständige Transkript würde hier fortgesetzt werden - aus Platzgründen hier gekürzt]
|
||||
[Das vollständige Transkript würde hier fortgesetzt werden - aus Platzgründen hier gekürzt]
|
||||
|
|
|
|||
|
|
@ -1,17 +1,26 @@
|
|||
---
|
||||
title: "Millennials in the Workplace"
|
||||
speaker: "Simon Sinek"
|
||||
speakerId: "simon-sinek"
|
||||
title: 'Millennials in the Workplace'
|
||||
speaker: 'Simon Sinek'
|
||||
speakerId: 'simon-sinek'
|
||||
date: 2017-01-01
|
||||
category: "leadership"
|
||||
tags: ["millennials", "workplace", "technology", "leadership", "generational-change", "social-media", "addiction"]
|
||||
venue: "Inside Quest Interview"
|
||||
duration: "15:18"
|
||||
videoUrl: "https://www.youtube.com/watch?v=hER0Qp6QJNU"
|
||||
thumbnail: "/images/talks/simon-sinek-millennials.jpg"
|
||||
category: 'leadership'
|
||||
tags:
|
||||
[
|
||||
'millennials',
|
||||
'workplace',
|
||||
'technology',
|
||||
'leadership',
|
||||
'generational-change',
|
||||
'social-media',
|
||||
'addiction',
|
||||
]
|
||||
venue: 'Inside Quest Interview'
|
||||
duration: '15:18'
|
||||
videoUrl: 'https://www.youtube.com/watch?v=hER0Qp6QJNU'
|
||||
thumbnail: '/images/talks/simon-sinek-millennials.jpg'
|
||||
readingTime: 18
|
||||
featured: true
|
||||
summary: "Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation. Er erklärt die vier Faktoren - gescheiterte Erziehung, Technologie-Sucht, Ungeduld und schlechte Arbeitsumgebungen - die zu den Problemen geführt haben, und warum Unternehmen die Verantwortung übernehmen müssen."
|
||||
summary: 'Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation. Er erklärt die vier Faktoren - gescheiterte Erziehung, Technologie-Sucht, Ungeduld und schlechte Arbeitsumgebungen - die zu den Problemen geführt haben, und warum Unternehmen die Verantwortung übernehmen müssen.'
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
|
@ -21,9 +30,11 @@ In diesem viral gegangenen Interview (über 100 Millionen Aufrufe) analysiert Si
|
|||
## 🎯 Die vier Hauptprobleme
|
||||
|
||||
### 1. Gescheiterte Erziehungsstrategien
|
||||
|
||||
> "They were told that they were special all the time. They were told that they can have anything they want in life, just because they want it."
|
||||
|
||||
**Die Probleme:**
|
||||
|
||||
- Ständig gesagt bekommen, sie seien "etwas Besonderes"
|
||||
- Partizipationsmedaillen für das Letztplatziert-Sein
|
||||
- Noten, die nicht verdient wurden, weil Eltern sich beschwerten
|
||||
|
|
@ -32,24 +43,29 @@ In diesem viral gegangenen Interview (über 100 Millionen Aufrufe) analysiert Si
|
|||
**Das Ergebnis:** Eine Generation mit niedrigerem Selbstwertgefühl, die schockiert ist, wenn die reale Welt nicht ihren Erwartungen entspricht.
|
||||
|
||||
### 2. Technologie-Sucht
|
||||
|
||||
> "We have age restrictions on smoking, gambling, and alcohol. And we have no age restrictions on social media and cell phones, which is the equivalent of opening up the liquor cabinet."
|
||||
|
||||
**Die Wissenschaft hinter der Sucht:**
|
||||
|
||||
- Social Media und Smartphones lösen Dopamin aus - denselben Stoff wie Alkohol, Zigaretten und Glücksspiel
|
||||
- Dopamin ist hochgradig suchterzeugend
|
||||
- Jugendliche bekommen uneingeschränkten Zugang zu diesen "Drogen" während der stressigsten Zeit ihres Lebens
|
||||
|
||||
**Die Konsequenzen:**
|
||||
|
||||
- Unfähigkeit, tiefe, bedeutungsvolle Beziehungen zu bilden
|
||||
- Oberflächliche Freundschaften, auf die man sich nicht verlassen kann
|
||||
- Keine gesunden Bewältigungsmechanismen für Stress
|
||||
- Höhere Depressionsraten bei Menschen, die mehr Zeit auf Social Media verbringen
|
||||
|
||||
### 3. Ungeduld und sofortiger Gratifikation
|
||||
|
||||
> "Everything you want, you can have instantaneously. Everything you want. Instant gratification. Except job satisfaction and strength of relationships, there ain't no app for that."
|
||||
|
||||
**Das Problem:**
|
||||
**Das Problem:**
|
||||
Eine Generation, die gewohnt ist, alles sofort zu bekommen:
|
||||
|
||||
- Amazon-Lieferung am nächsten Tag
|
||||
- Binge-Watching von TV-Shows
|
||||
- Dating-Apps statt echte soziale Interaktion
|
||||
|
|
@ -58,9 +74,11 @@ Eine Generation, die gewohnt ist, alles sofort zu bekommen:
|
|||
**Die Realität:** Jobzufriedenheit und starke Beziehungen sind "slow, meandering, uncomfortable, messy processes" - sie brauchen Zeit.
|
||||
|
||||
### 4. Schlechte Corporate Umgebungen
|
||||
|
||||
> "We're putting them in corporate environments that care more about the numbers than they do about the kids. They care more about the short term gains than the long term life of this young human being."
|
||||
|
||||
**Das Problem:** Unternehmen, die:
|
||||
|
||||
- Sich mehr für kurzfristige Gewinne als für Menschen interessieren
|
||||
- Nicht helfen, Vertrauen aufzubauen
|
||||
- Keine Kooperationsfähigkeiten vermitteln
|
||||
|
|
@ -70,16 +88,19 @@ Eine Generation, die gewohnt ist, alles sofort zu bekommen:
|
|||
## 💡 Kernerkenntnisse
|
||||
|
||||
### Die Sucht-Analogie
|
||||
|
||||
> "Almost every alcoholic discovered alcohol when they were teenagers... Social stress, financial stress, career stress. That's pretty much the primary reasons why an alcoholic drinks."
|
||||
|
||||
Sinek zieht eine erschreckende Parallele: Jugendliche, die in stressigen Zeiten zu Alkohol greifen, werden konditioniert, bei jedem zukünftigen Stress zur Flasche zu greifen. Genauso werden Jugendliche, die zu Social Media greifen, konditioniert, bei Stress nicht zu Menschen, sondern zu Geräten zu gehen.
|
||||
|
||||
### Das Beziehungsproblem
|
||||
|
||||
> "Their words, not mine. They will admit that many of their friendships are superficial. They will admit that their friends, that they don't count on their friends, they don't rely on their friends, they have fun with their friends. But they also know that their friends will cancel out of them if something better comes along."
|
||||
|
||||
Die Generation hat keine tiefen Beziehungen, weil sie nie gelernt hat, wie man sie aufbaut.
|
||||
|
||||
### Der Berg-Metapher
|
||||
|
||||
> "It's as if they're standing at the foot of a mountain and they have this abstract concept called impact that they want to have in the world, which is the summit. What they don't see is the mountain."
|
||||
|
||||
Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfüllung Zeit und harte Arbeit erfordert.
|
||||
|
|
@ -89,6 +110,7 @@ Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfü
|
|||
### Für Unternehmen:
|
||||
|
||||
**1. Handyfreie Konferenzräume**
|
||||
|
||||
> "There should be no cell phones in conference rooms, none, zero."
|
||||
|
||||
- Kein "Handy auf stumm" - komplett weg
|
||||
|
|
@ -96,6 +118,7 @@ Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfü
|
|||
- "How's your dad? I heard he was in the hospital" - so bildet sich Vertrauen
|
||||
|
||||
**2. Vertrauen langsam aufbauen**
|
||||
|
||||
> "Trust doesn't form in an event in a day... It's the slow, steady consistency."
|
||||
|
||||
- Kleine, regelmäßige Interaktionen ermöglichen
|
||||
|
|
@ -103,6 +126,7 @@ Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfü
|
|||
- Geduld mit der Entwicklung haben
|
||||
|
||||
**3. Die Verantwortung übernehmen**
|
||||
|
||||
> "It's the company's responsibility, sucks to be you... We have no choice. This is what we got."
|
||||
|
||||
- Soziale Fähigkeiten aktiv lehren
|
||||
|
|
@ -112,16 +136,19 @@ Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfü
|
|||
### Für Individuen:
|
||||
|
||||
**1. Digitale Entgiftung**
|
||||
|
||||
- Handy nicht neben dem Bett laden ("Buy an alarm clock. They cost $8.")
|
||||
- Bei Restaurantbesuchen das Handy zu Hause lassen
|
||||
- Bewusst Momente der Langeweile zulassen - dort entstehen Ideen
|
||||
|
||||
**2. Echte Beziehungen fördern**
|
||||
|
||||
- Face-to-face Gespräche suchen
|
||||
- Oberflächliche Interaktionen in tiefe verwandeln
|
||||
- Hilfe anbieten und annehmen
|
||||
|
||||
**3. Geduld lernen**
|
||||
|
||||
- Verstehen, dass Karriere-Erfüllung Zeit braucht
|
||||
- Kleine Schritte feiern
|
||||
- Langfristige Perspektive entwickeln
|
||||
|
|
@ -129,22 +156,27 @@ Millennials wollen sofort "Impact" haben, verstehen aber nicht, dass echte Erfü
|
|||
## 🧠 Psychologische Prinzipien
|
||||
|
||||
### Dopamin und Sucht
|
||||
|
||||
Die Erklärung, wie Dopamin funktioniert, ist zentral für das Verständnis der Generation. Das Gehirn wird auf sofortige Befriedigung konditioniert, was langfristige Ziele erschwert.
|
||||
|
||||
### Entwicklungspsychologie
|
||||
|
||||
Der Übergang von elterlicher Zustimmung zu Peer-Zustimmung während der Adoleszenz ist kritisch. Wenn diese Phase durch Technologie gestört wird, entstehen lebenslange Probleme.
|
||||
|
||||
### Soziale Konditionierung
|
||||
|
||||
Menschen lernen durch Wiederholung. Wenn junge Menschen lernen, bei Stress zu Geräten statt zu Menschen zu gehen, wird das zu einem lebenslangen Muster.
|
||||
|
||||
## ⚠️ Die düsteren Szenarien
|
||||
|
||||
### Worst Case:
|
||||
|
||||
- Steigende Selbstmordraten
|
||||
- Mehr versehentliche Todesfälle durch Überdosen
|
||||
- Mehr Schulabbrecher wegen Depression
|
||||
|
||||
### Best Case:
|
||||
|
||||
> "You'll have an entire population growing up and going through life and just never really finding joy... How's your job? It's fine. How's your relationship? It's fine."
|
||||
|
||||
Ein Leben ohne echte Erfüllung oder Freude.
|
||||
|
|
@ -154,6 +186,7 @@ Ein Leben ohne echte Erfüllung oder Freude.
|
|||
Dieses Interview ist viral gegangen, weil es schmerzhaft ehrlich ein Problem anspricht, das viele spüren, aber nicht artikulieren können. Sinek gibt nicht nur den Millennials die Schuld - er erklärt systematisch, wie die Gesellschaft versagt hat und wie Unternehmen jetzt handeln müssen.
|
||||
|
||||
Es ist ein Weckruf für:
|
||||
|
||||
- **Eltern:** Ihre Kinder besser auf die reale Welt vorzubereiten
|
||||
- **Unternehmen:** Verantwortung für die Entwicklung ihrer jungen Mitarbeiter zu übernehmen
|
||||
- **Millennials:** Zu verstehen, dass es nicht ihre Schuld ist, aber sie trotzdem handeln müssen
|
||||
|
|
@ -165,50 +198,50 @@ Das Interview ist gleichzeitig eine Diagnose und ein Aufruf zum Handeln - und ze
|
|||
|
||||
## Vollständiges Transkript
|
||||
|
||||
*Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video.*
|
||||
_Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video._
|
||||
|
||||
What's the millennial question? Apparently, millennials as a generation, which is a group of people who were born approximately in 1984 and after, are tough to manage. And they're accused of being entitled in narcissistic, self-interested, unfocused, lazy. But entitled is the big one. And because they confound leadership so much, what's happening is leaders are asking the millennials, what do you want? And millennials are saying, we want to work in a place with purpose, love that. We want to make an impact, you know, whatever that means. We want free food and bean bags. And so somebody articulates some sort of purpose. There's lots of free food and there's bean bags. And yet for some reason, they are still not happy. And that's because they're missing piece.
|
||||
What's the millennial question? Apparently, millennials as a generation, which is a group of people who were born approximately in 1984 and after, are tough to manage. And they're accused of being entitled in narcissistic, self-interested, unfocused, lazy. But entitled is the big one. And because they confound leadership so much, what's happening is leaders are asking the millennials, what do you want? And millennials are saying, we want to work in a place with purpose, love that. We want to make an impact, you know, whatever that means. We want free food and bean bags. And so somebody articulates some sort of purpose. There's lots of free food and there's bean bags. And yet for some reason, they are still not happy. And that's because they're missing piece.
|
||||
|
||||
What I've learned is that I can break it down into four pieces. There are four things, four characteristics. One is parenting. The other one is technology. The third is impatience and the fourth is environment.
|
||||
What I've learned is that I can break it down into four pieces. There are four things, four characteristics. One is parenting. The other one is technology. The third is impatience and the fourth is environment.
|
||||
|
||||
The generation that we call the millennials, too many of them grew up subject to, not my words, failed parenting strategies, you know, where, for example, they were told that they were special all the time. They were told that they can have anything they want in life, just because they want it. They were told some of them got into honors classes, not because they deserved it, but because their parents complained. And some of them got A's, not because they earned them, but because the teachers didn't want to deal with the parents. Some kids got participation medals. They got a medal for coming in last, right? Which the science we know is pretty clear, which is it devalues the medal and the reward for those who actually work hard. And it actually makes the person who comes in last feel embarrassed because they know they didn't deserve it. So it actually makes them feel worse, right?
|
||||
The generation that we call the millennials, too many of them grew up subject to, not my words, failed parenting strategies, you know, where, for example, they were told that they were special all the time. They were told that they can have anything they want in life, just because they want it. They were told some of them got into honors classes, not because they deserved it, but because their parents complained. And some of them got A's, not because they earned them, but because the teachers didn't want to deal with the parents. Some kids got participation medals. They got a medal for coming in last, right? Which the science we know is pretty clear, which is it devalues the medal and the reward for those who actually work hard. And it actually makes the person who comes in last feel embarrassed because they know they didn't deserve it. So it actually makes them feel worse, right?
|
||||
|
||||
So you take this group of people and they graduate school and they get a job and they're thrust into the real world. And in an instant they find out they're not special, their moms can't get them a promotion, that you get nothing for coming in last. And by the way, you can't just have it because you want it, right? And in an instant their entire self-image is shattered. And so you have an entire generation that's growing up with lower self-esteem than previous generations.
|
||||
So you take this group of people and they graduate school and they get a job and they're thrust into the real world. And in an instant they find out they're not special, their moms can't get them a promotion, that you get nothing for coming in last. And by the way, you can't just have it because you want it, right? And in an instant their entire self-image is shattered. And so you have an entire generation that's growing up with lower self-esteem than previous generations.
|
||||
|
||||
The other problem to compound it is we're growing up in a Facebook Instagram world, in other words we're putting filters on things. We're good at showing people that life is amazing even though I'm depressed, right? And so everybody sounds tough and everybody sounds like they got it all figured out. And the reality is there's very little toughness and most people don't have it figured out. And so when the more senior people say, well what should we do? They sound like this is what you got at it. And they have no clue. Right? So you have an entire generation growing up with lower self-esteem than previous generations. Right? Through no fault of their own. Through no fault of their own. Right? They were dealt a bad hand. Right?
|
||||
The other problem to compound it is we're growing up in a Facebook Instagram world, in other words we're putting filters on things. We're good at showing people that life is amazing even though I'm depressed, right? And so everybody sounds tough and everybody sounds like they got it all figured out. And the reality is there's very little toughness and most people don't have it figured out. And so when the more senior people say, well what should we do? They sound like this is what you got at it. And they have no clue. Right? So you have an entire generation growing up with lower self-esteem than previous generations. Right? Through no fault of their own. Through no fault of their own. Right? They were dealt a bad hand. Right?
|
||||
|
||||
Now let's add in technology. We know that engagement with social media and our cell phones releases a chemical called dopamine. That's why when you get a text feels good. Right? So you know we've all had it where you're feeling a little bit down or feeling a little bit lonely. And so you send out 10 texts to 10 friends. You know, hi, hi, hi, hi. Because it feels good when you get a response. Right? Right? It's why we count the likes. It's why we go back 10 times to see if it's going if my Instagram is growing slower. I do something wrong. Do they not like me anymore? The trauma for young kids to be unfriended. Right? Because we know when you get it, you get a dopamine which feels good. It's why we like it. It's why we keep going back to it.
|
||||
Now let's add in technology. We know that engagement with social media and our cell phones releases a chemical called dopamine. That's why when you get a text feels good. Right? So you know we've all had it where you're feeling a little bit down or feeling a little bit lonely. And so you send out 10 texts to 10 friends. You know, hi, hi, hi, hi. Because it feels good when you get a response. Right? Right? It's why we count the likes. It's why we go back 10 times to see if it's going if my Instagram is growing slower. I do something wrong. Do they not like me anymore? The trauma for young kids to be unfriended. Right? Because we know when you get it, you get a dopamine which feels good. It's why we like it. It's why we keep going back to it.
|
||||
|
||||
Dopamine is the exact same chemical that makes us feel good when we smoke, when we drink, and when we gamble. In other words, it's highly, highly addictive. Right? We have age restrictions on smoking, gambling, and alcohol. And we have no age restrictions on social media and cell phones, which is the equivalent of opening up the liquor cabinet and saying to our teenagers, hey, by the way, this adolescence thing of it gets you down. But that's basically what's happening. That's basically what's happening. Right? That's basically what happened.
|
||||
Dopamine is the exact same chemical that makes us feel good when we smoke, when we drink, and when we gamble. In other words, it's highly, highly addictive. Right? We have age restrictions on smoking, gambling, and alcohol. And we have no age restrictions on social media and cell phones, which is the equivalent of opening up the liquor cabinet and saying to our teenagers, hey, by the way, this adolescence thing of it gets you down. But that's basically what's happening. That's basically what's happening. Right? That's basically what happened.
|
||||
|
||||
You have an entire generation that has access to an addictive numbing chemical dopamine through social media and cell phones as they're going through the high stress of adolescence. Why is this important? Almost every alcoholic discovered alcohol when they were teenagers. When we're very, very young, the only approval we need is the approval of our parents. And as we go through adolescence, we make this transition where we now need the approval of our peers. Very frustrating for our parents. Very important for us, it allows us to acculturate outside of our immediate families into the broader tribe. Right? It's a highly, highly stressful and anxious period of our lives. And we're supposed to learn to rely on our friends.
|
||||
You have an entire generation that has access to an addictive numbing chemical dopamine through social media and cell phones as they're going through the high stress of adolescence. Why is this important? Almost every alcoholic discovered alcohol when they were teenagers. When we're very, very young, the only approval we need is the approval of our parents. And as we go through adolescence, we make this transition where we now need the approval of our peers. Very frustrating for our parents. Very important for us, it allows us to acculturate outside of our immediate families into the broader tribe. Right? It's a highly, highly stressful and anxious period of our lives. And we're supposed to learn to rely on our friends.
|
||||
|
||||
Some people, quite by accident, discover alcohol and numbing effects of dopamine to help them cope with the stresses and anxieties of adolescence. Unfortunately, that becomes hardwired in their brains. And for the rest of their lives, when they suffer significant stress, they will not turn to a person, they will turn to the bottle. Social stress, financial stress, career stress. That's pretty much the primary reasons why an alcoholic drinks. Right?
|
||||
Some people, quite by accident, discover alcohol and numbing effects of dopamine to help them cope with the stresses and anxieties of adolescence. Unfortunately, that becomes hardwired in their brains. And for the rest of their lives, when they suffer significant stress, they will not turn to a person, they will turn to the bottle. Social stress, financial stress, career stress. That's pretty much the primary reasons why an alcoholic drinks. Right?
|
||||
|
||||
What's happening is because we're allowing unfettered access to these dopamine producing devices and media. Basically, it's becoming hardwired and what we're seeing is as they grow older, they too many kids don't know how to form deep, meaningful relationships. Their words, not mine. They will admit that many of their friendships are superficial. They will admit that their friends, that they don't count on their friends, they don't rely on their friends, they have fun with their friends. But they also know that their friends will cancel out of them if something better comes along. Deep, meaningful relationships are not there because they never practice the skill set and worse, they don't have the coping mechanisms to deal with stress. So when significant stress starts to show up in their lives, they're not turning to a person, they're turning to a device, they're turning to social media, they're turning to these things which offer temporary relief.
|
||||
What's happening is because we're allowing unfettered access to these dopamine producing devices and media. Basically, it's becoming hardwired and what we're seeing is as they grow older, they too many kids don't know how to form deep, meaningful relationships. Their words, not mine. They will admit that many of their friendships are superficial. They will admit that their friends, that they don't count on their friends, they don't rely on their friends, they have fun with their friends. But they also know that their friends will cancel out of them if something better comes along. Deep, meaningful relationships are not there because they never practice the skill set and worse, they don't have the coping mechanisms to deal with stress. So when significant stress starts to show up in their lives, they're not turning to a person, they're turning to a device, they're turning to social media, they're turning to these things which offer temporary relief.
|
||||
|
||||
We know, the science is clear, we know that people who spend more time on Facebook suffer higher rates of depression than people who spend less time on Facebook. Right? These things balanced. Alcohol is not bad, too much alcohol is bad. Gambling is fun, too much gambling is dangerous. Right? There's nothing wrong with social media and cell phones. It's the imbalance. Right? If you're sitting at dinner with your friends and you're texting somebody who's not there, that's a problem, that's an addiction. If you're sitting in a meeting with people you're supposed to be listening to and speaking and you put your phone on the table, face up or face down, I don't care. That sends a subconscious message to the room that you're not just not that important to me right now. Right? That's what happens. And the fact that you cannot put it away is because you are addicted. Right?
|
||||
We know, the science is clear, we know that people who spend more time on Facebook suffer higher rates of depression than people who spend less time on Facebook. Right? These things balanced. Alcohol is not bad, too much alcohol is bad. Gambling is fun, too much gambling is dangerous. Right? There's nothing wrong with social media and cell phones. It's the imbalance. Right? If you're sitting at dinner with your friends and you're texting somebody who's not there, that's a problem, that's an addiction. If you're sitting in a meeting with people you're supposed to be listening to and speaking and you put your phone on the table, face up or face down, I don't care. That sends a subconscious message to the room that you're not just not that important to me right now. Right? That's what happens. And the fact that you cannot put it away is because you are addicted. Right?
|
||||
|
||||
If you wake up and you check your phone before you say good morning to your girlfriend, boyfriend or spouse, you have an addiction. And like all addiction in time, it'll destroy relationships, it'll cost time, it'll cost money, it'll make your life worse. Right? So you have a generation growing up with lower self-esteem that doesn't have the coping mechanisms to do with stress. Stress. Right?
|
||||
If you wake up and you check your phone before you say good morning to your girlfriend, boyfriend or spouse, you have an addiction. And like all addiction in time, it'll destroy relationships, it'll cost time, it'll cost money, it'll make your life worse. Right? So you have a generation growing up with lower self-esteem that doesn't have the coping mechanisms to do with stress. Stress. Right?
|
||||
|
||||
Now you add in the sense of impatience. Right? They've grown up in a world of instant gratification. You want to buy something, you go on Amazon and it arrives the next day. You want to watch a movie? Log on and watch a movie. You don't check movie times. You want to watch a TV show? Binge. You don't even have to wait week to week to week. Right? I know people who skip seasons just so they can binge at the end of the season. Right? Instagram gratification. You want to go on a date? You don't even have to learn how to be like, hey! You don't even have to learn and practice that skill. You don't have to be the uncomfortable one with cis-yes when you mean knowns, no when you mean knowns, yes when you- you don't have to swipe right. Bang, I'm a stud! Right? You don't have to learn the social coping mechanisms. Right?
|
||||
Now you add in the sense of impatience. Right? They've grown up in a world of instant gratification. You want to buy something, you go on Amazon and it arrives the next day. You want to watch a movie? Log on and watch a movie. You don't check movie times. You want to watch a TV show? Binge. You don't even have to wait week to week to week. Right? I know people who skip seasons just so they can binge at the end of the season. Right? Instagram gratification. You want to go on a date? You don't even have to learn how to be like, hey! You don't even have to learn and practice that skill. You don't have to be the uncomfortable one with cis-yes when you mean knowns, no when you mean knowns, yes when you- you don't have to swipe right. Bang, I'm a stud! Right? You don't have to learn the social coping mechanisms. Right?
|
||||
|
||||
Everything you want, you can have instantaneously. Everything you want. Instant gratification. Except job satisfaction and strength of relationships, there ain't no app for that. They are slow, meandering, uncomfortable, messy processes. And so I keep meeting these wonderful, fantastic, idealistic, hardworking, smart kids, they've just graduated school, they're in their entry level job. I sit down with them and I go, how's it going? They go, I think I'm going to quit. I'm like, why? They're like, I'm not making an impact. I'm like, you've been here eight months.
|
||||
Everything you want, you can have instantaneously. Everything you want. Instant gratification. Except job satisfaction and strength of relationships, there ain't no app for that. They are slow, meandering, uncomfortable, messy processes. And so I keep meeting these wonderful, fantastic, idealistic, hardworking, smart kids, they've just graduated school, they're in their entry level job. I sit down with them and I go, how's it going? They go, I think I'm going to quit. I'm like, why? They're like, I'm not making an impact. I'm like, you've been here eight months.
|
||||
|
||||
You know what? It's as if they're standing at the foot of a mountain and they have this abstract concept called impact that they want to have in the world, which is the summit. What they don't see is the mountain. I don't care if you go up the mountain quickly or slowly, but they're still a mountain. And so what this young generation needs to learn is patience, that some things that really, really matter, like love, or job fulfillment, joy, love of life, self-confidence, a skill set, any of these things, all of these things take time. Sometimes you can expedite pieces of it, but the overall journey is arduous and long and difficult. And if you don't ask for help and learn that skill set, you will fall off the mountain or you will, the worst case scenario.
|
||||
You know what? It's as if they're standing at the foot of a mountain and they have this abstract concept called impact that they want to have in the world, which is the summit. What they don't see is the mountain. I don't care if you go up the mountain quickly or slowly, but they're still a mountain. And so what this young generation needs to learn is patience, that some things that really, really matter, like love, or job fulfillment, joy, love of life, self-confidence, a skill set, any of these things, all of these things take time. Sometimes you can expedite pieces of it, but the overall journey is arduous and long and difficult. And if you don't ask for help and learn that skill set, you will fall off the mountain or you will, the worst case scenario.
|
||||
|
||||
The worst case scenario, and we're already seeing it. The worst case scenario is we're seeing increase in suicide rates, we're seeing an increase in this generation, we're seeing an increase in accidental deaths due to drug overdoses, we're seeing more and more kids drop out of school or take leads of absence due to depression. Unheard of, this is really bad. The best case scenario, the best, those are all bad cases, right? The best case scenario is you'll have an entire population growing up and going through life and just never really finding joy. They'll never really find deep, deep fulfillment in work or in life, they'll just wath through life and it'll just, it's fine. How's your job? It's fine, the same as yesterday. How's your relationship? It's fine. Like that's the best case scenario, which leads me to the fourth point, which is environment, which is we're taking this amazing group of young, fantastic kids who just dealt a bad hand, it's no fault of their own.
|
||||
The worst case scenario, and we're already seeing it. The worst case scenario is we're seeing increase in suicide rates, we're seeing an increase in this generation, we're seeing an increase in accidental deaths due to drug overdoses, we're seeing more and more kids drop out of school or take leads of absence due to depression. Unheard of, this is really bad. The best case scenario, the best, those are all bad cases, right? The best case scenario is you'll have an entire population growing up and going through life and just never really finding joy. They'll never really find deep, deep fulfillment in work or in life, they'll just wath through life and it'll just, it's fine. How's your job? It's fine, the same as yesterday. How's your relationship? It's fine. Like that's the best case scenario, which leads me to the fourth point, which is environment, which is we're taking this amazing group of young, fantastic kids who just dealt a bad hand, it's no fault of their own.
|
||||
|
||||
And we put them in corporate environments that care more about the numbers than they do about the kids. They care more about the short term gains than the long term life of this young human being. We care more about the year than the lifetime, right? And so we are putting them in corporate environments that aren't helping them build their confidence, that aren't helping them learn the skills of cooperation, that aren't helping them overcome the challenges of a digital world and finding more balance. That isn't helping them overcome the need to have instant gratification and teach them the joys and impact and the fulfillment you get from working hard over something for a long time that cannot be done in a month or even in a year.
|
||||
And we put them in corporate environments that care more about the numbers than they do about the kids. They care more about the short term gains than the long term life of this young human being. We care more about the year than the lifetime, right? And so we are putting them in corporate environments that aren't helping them build their confidence, that aren't helping them learn the skills of cooperation, that aren't helping them overcome the challenges of a digital world and finding more balance. That isn't helping them overcome the need to have instant gratification and teach them the joys and impact and the fulfillment you get from working hard over something for a long time that cannot be done in a month or even in a year.
|
||||
|
||||
And so with thrusting them in corporate environments and the worst part about it is they think it's them, they blame themselves, they think it's them who can't deal. And so it makes it all worse. It's not, I'm here to tell them, it's not them. It's the corporations, it's the corporate environments, it's the total lack of good leadership in our world today that is making them feel the way they do. They would dealt a bad hand and I hate to say it but it's the company's responsibility, sucks to be you, like we have no choice. This is what we got and I wish that society and their parents did a better job, they didn't. So we're getting them in our companies and we now have to pick up the slack.
|
||||
And so with thrusting them in corporate environments and the worst part about it is they think it's them, they blame themselves, they think it's them who can't deal. And so it makes it all worse. It's not, I'm here to tell them, it's not them. It's the corporations, it's the corporate environments, it's the total lack of good leadership in our world today that is making them feel the way they do. They would dealt a bad hand and I hate to say it but it's the company's responsibility, sucks to be you, like we have no choice. This is what we got and I wish that society and their parents did a better job, they didn't. So we're getting them in our companies and we now have to pick up the slack.
|
||||
|
||||
We have to work extra hard to figure out the ways that we build their confidence. We have to work extra hard to find ways to teach them social skills that they're missing out on. There should be no cell phones in conference rooms, none, zero. And I don't mean the kind of like sitting outside waiting to text. I mean like when you're sitting and waiting for a meeting to start, nobody goes, this is what we all do, we all sit here and wait for the meeting to start. Meeting starting, okay, and we start the meeting. No, that's not how relationships are formed. Remember we talked about it's the little things?
|
||||
We have to work extra hard to figure out the ways that we build their confidence. We have to work extra hard to find ways to teach them social skills that they're missing out on. There should be no cell phones in conference rooms, none, zero. And I don't mean the kind of like sitting outside waiting to text. I mean like when you're sitting and waiting for a meeting to start, nobody goes, this is what we all do, we all sit here and wait for the meeting to start. Meeting starting, okay, and we start the meeting. No, that's not how relationships are formed. Remember we talked about it's the little things?
|
||||
|
||||
Relationships are formed this way. We're waiting for a meeting to start and we go, how's your dad? I heard he was in the hospital. Oh, he's really good, thanks for asking. He's actually at home now. Oh, I'm really glad, it was really amazing. I know, it was really scary for a while. That's how you form relationships. Hey, did you ever get that report on, oh my god, no, I didn't. I'll help you out. I totally can help you out with that. Really? That's how trust forms. Trust doesn't form in an event in a day, even bad times don't form trust immediately. It's the slow, steady consistency. And we have to create mechanisms where we allow for those little innocuous interactions to happen. But when we allow cell phones and conference rooms, we just, okay, have the meeting.
|
||||
Relationships are formed this way. We're waiting for a meeting to start and we go, how's your dad? I heard he was in the hospital. Oh, he's really good, thanks for asking. He's actually at home now. Oh, I'm really glad, it was really amazing. I know, it was really scary for a while. That's how you form relationships. Hey, did you ever get that report on, oh my god, no, I didn't. I'll help you out. I totally can help you out with that. Really? That's how trust forms. Trust doesn't form in an event in a day, even bad times don't form trust immediately. It's the slow, steady consistency. And we have to create mechanisms where we allow for those little innocuous interactions to happen. But when we allow cell phones and conference rooms, we just, okay, have the meeting.
|
||||
|
||||
And then my favorite is like when there's a cell phone there and you go like, there she go. It rings and you go, I'm not going to answer that. Mr. Magnanimous, you know? You're out for dinner with your friends. Like, I do this with my friends. When we're going out for dinner and we're leaving together, we'll leave our cell phones at home. Who are we calling? Maybe one of us will bring a phone in case we need to call an Uber or take a picture of our meal. It was a saying, come on. I mean, I'm not, I'm an idealist, but I'm not insane. I mean, it looked really good. We'll take one phone.
|
||||
And then my favorite is like when there's a cell phone there and you go like, there she go. It rings and you go, I'm not going to answer that. Mr. Magnanimous, you know? You're out for dinner with your friends. Like, I do this with my friends. When we're going out for dinner and we're leaving together, we'll leave our cell phones at home. Who are we calling? Maybe one of us will bring a phone in case we need to call an Uber or take a picture of our meal. It was a saying, come on. I mean, I'm not, I'm an idealist, but I'm not insane. I mean, it looked really good. We'll take one phone.
|
||||
|
||||
And so it's like an alcoholic. The reason you take the alcohol out of the house is because we cannot trust our willpower. We're just not strong enough. But when you remove the temptation, it actually makes it a lot easier. And so when you just say, don't check your phone, people literally will go like this and somebody will go to the bathroom and what's the first thing we do? Because I wouldn't want to look around the restaurant for a minute and a half, you know? But if you don't have the phone, you just kind of enjoy the world. And that's where ideas happen. The constant, constant, constant engagements, not where you have innovation and ideas. Ideas happen when our minds wander and we go, and you see something, I don't know if they could do that. That's called innovation. But we're taking away all those little moments.
|
||||
And so it's like an alcoholic. The reason you take the alcohol out of the house is because we cannot trust our willpower. We're just not strong enough. But when you remove the temptation, it actually makes it a lot easier. And so when you just say, don't check your phone, people literally will go like this and somebody will go to the bathroom and what's the first thing we do? Because I wouldn't want to look around the restaurant for a minute and a half, you know? But if you don't have the phone, you just kind of enjoy the world. And that's where ideas happen. The constant, constant, constant engagements, not where you have innovation and ideas. Ideas happen when our minds wander and we go, and you see something, I don't know if they could do that. That's called innovation. But we're taking away all those little moments.
|
||||
|
||||
You should not, and none of us, none of us should charge our phones by our beds. We should be charging our phones in the living rooms. Right? Remove the temptation. You wake up in the middle of the night because you can't sleep. You won't check your phone, which makes it worse. But if it's in the living room, it's relaxed. It's fine. But it's my alarm clock. Buy an alarm clock. They cost $8. I'll buy you an alarm clock.
|
||||
You should not, and none of us, none of us should charge our phones by our beds. We should be charging our phones in the living rooms. Right? Remove the temptation. You wake up in the middle of the night because you can't sleep. You won't check your phone, which makes it worse. But if it's in the living room, it's relaxed. It's fine. But it's my alarm clock. Buy an alarm clock. They cost $8. I'll buy you an alarm clock.
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
---
|
||||
title: "Why Good Leaders Make You Feel Safe"
|
||||
speaker: "Simon Sinek"
|
||||
speakerId: "simon-sinek"
|
||||
title: 'Why Good Leaders Make You Feel Safe'
|
||||
speaker: 'Simon Sinek'
|
||||
speakerId: 'simon-sinek'
|
||||
date: 2014-03-01
|
||||
category: "leadership"
|
||||
tags: ["leadership", "trust", "safety", "team-building", "management", "psychology"]
|
||||
venue: "TED"
|
||||
duration: "11:59"
|
||||
videoUrl: "https://www.youtube.com/watch?v=lmyZMtPVodo"
|
||||
thumbnail: "/images/talks/simon-sinek-feel-safe.jpg"
|
||||
category: 'leadership'
|
||||
tags: ['leadership', 'trust', 'safety', 'team-building', 'management', 'psychology']
|
||||
venue: 'TED'
|
||||
duration: '11:59'
|
||||
videoUrl: 'https://www.youtube.com/watch?v=lmyZMtPVodo'
|
||||
thumbnail: '/images/talks/simon-sinek-feel-safe.jpg'
|
||||
readingTime: 12
|
||||
featured: true
|
||||
summary: "Simon Sinek erklärt, warum echte Führungskräfte einen 'Circle of Safety' schaffen müssen, in dem sich Menschen geschützt fühlen und ihr volles Potenzial entfalten können. Ein kraftvoller Vortrag über Vertrauen, Kooperation und die wahre Bedeutung von Leadership."
|
||||
|
|
@ -21,19 +21,23 @@ In diesem beeindruckenden TED Talk zeigt Simon Sinek anhand der Geschichte von C
|
|||
## 🎯 Kernerkenntnisse
|
||||
|
||||
### 1. Der Circle of Safety
|
||||
|
||||
> "When we feel safe inside the organization, we will naturally combine our talents and our strengths and work tirelessly to face the dangers outside."
|
||||
|
||||
Genau wie unsere Vorfahren in der Steinzeit brauchen Menschen heute einen geschützten Raum, in dem sie Vertrauen und Kooperation entwickeln können. Führungskräfte sind dafür verantwortlich, diesen Schutzraum zu schaffen.
|
||||
|
||||
### 2. Leadership ist eine Wahl, kein Rang
|
||||
|
||||
> "Leadership is a choice. It is not a rank."
|
||||
|
||||
Echte Führung hat nichts mit der Position in der Hierarchie zu tun. Viele Menschen in Spitzenpositionen sind keine Führungskräfte, während Menschen ohne formelle Autorität durch ihre Taten echte Führung zeigen.
|
||||
|
||||
### 3. Leaders Eat Last
|
||||
|
||||
Die Geschichte der Marines, wo Offiziere als letztes essen, illustriert ein fundamentales Prinzip: Echte Führungskräfte stellen die Bedürfnisse ihrer Menschen vor ihre eigenen. Sie gehen die Risiken zuerst ein und sorgen dafür, dass ihr Team sicher ist.
|
||||
|
||||
### 4. Das Problem mit modernen Unternehmen
|
||||
|
||||
> "In business, we give bonuses to people who are willing to sacrifice others so that we may gain. In the military, they give medals to people who are willing to sacrifice themselves so that others may gain."
|
||||
|
||||
Sinek kritisiert die pervertierten Anreizsysteme vieler Unternehmen, die Selbstsucht belohnen statt Dienstbereitschaft.
|
||||
|
|
@ -53,29 +57,36 @@ Sinek kritisiert die pervertierten Anreizsysteme vieler Unternehmen, die Selbsts
|
|||
## 📚 Zentrale Konzepte
|
||||
|
||||
### Captain William Swenson
|
||||
|
||||
Der Vortrag beginnt mit der bewegenden Geschichte von Captain Swenson, der die Congressional Medal of Honor erhielt. Sinek zeigt das berührende Video, wie Swenson einen verwundeten Soldaten küsst, bevor er ihn in den Rettungshubschrauber lädt.
|
||||
|
||||
### Evolutionäre Psychologie
|
||||
|
||||
Sinek erklärt, wie Menschen sich als soziale Wesen entwickelt haben, die in Gruppen überleben. Das Gefühl von Sicherheit innerhalb der eigenen "Tribe" ermöglicht Vertrauen und Kooperation - Gefühle, die nicht befohlen werden können.
|
||||
|
||||
### Das Flughafen-Beispiel
|
||||
|
||||
Eine Gate-Agentin behandelt Passagiere schlecht, weil sie Angst hat, ihren Job zu verlieren. Dies zeigt, wie Angst und mangelnde psychologische Sicherheit zu schlechtem Service führen.
|
||||
|
||||
### Charlie Kim und Next Jump
|
||||
|
||||
Das Beispiel eines CEOs, der Lifetime Employment einführt und seine Mitarbeiter wie Familie behandelt, zeigt, wie echte Führung in der Praxis aussieht.
|
||||
|
||||
### Bob Chapman und Barry Wehmiller
|
||||
|
||||
Während der Finanzkrise 2008 führte Chapman ein Freistellungsprogramm ein, bei dem alle Mitarbeiter einige Wochen unbezahlten Urlaub nehmen mussten, anstatt Menschen zu entlassen. Seine Botschaft: "Es ist besser, dass wir alle ein wenig leiden, als dass einige von uns viel leiden müssen."
|
||||
|
||||
## 🔥 Praktische Anwendung
|
||||
|
||||
### Für Führungskräfte:
|
||||
|
||||
1. **Schaffen Sie psychologische Sicherheit** - Sorgen Sie dafür, dass sich Ihr Team sicher fühlt, Risiken einzugehen und Fehler zu machen
|
||||
2. **Gehen Sie mit gutem Beispiel voran** - Übernehmen Sie Risiken, bevor Sie sie von anderen verlangen
|
||||
3. **Investieren Sie in Menschen, nicht nur in Zahlen** - Langfristiger Erfolg kommt durch starke Teams
|
||||
4. **Seien Sie bereit zu dienen** - Stellen Sie die Bedürfnisse Ihrer Mitarbeiter vor Ihre eigenen
|
||||
|
||||
### Für Organisationen:
|
||||
|
||||
1. **Überdenken Sie Anreizsysteme** - Belohnen Sie Kooperation und Teamwork, nicht nur individuelle Leistung
|
||||
2. **Investieren Sie in Vertrauen** - Schaffen Sie Systeme, die Vertrauen fördern statt Misstrauen
|
||||
3. **Langfristig denken** - Kurzfristige Gewinne auf Kosten der Menschen sind nicht nachhaltig
|
||||
|
|
@ -83,12 +94,15 @@ Während der Finanzkrise 2008 führte Chapman ein Freistellungsprogramm ein, bei
|
|||
## 🧠 Psychologische Prinzipien
|
||||
|
||||
### Das Sicherheitsbedürfnis
|
||||
|
||||
Menschen haben ein grundlegendes Bedürfnis nach Sicherheit. Wenn dieses erfüllt ist, können sie ihr volles kreatives und produktives Potenzial entfalten.
|
||||
|
||||
### Vertrauen als Grundlage
|
||||
|
||||
Vertrauen kann nicht befohlen werden - es entsteht durch konsistente Handlungen und das Gefühl, dass die Führungskraft die Interessen des Teams im Herzen trägt.
|
||||
|
||||
### Die Macht der Reziprozität
|
||||
|
||||
Wenn Führungskräfte bereit sind, für ihr Team Opfer zu bringen, werden die Teammitglieder dasselbe für die Führungskraft tun.
|
||||
|
||||
## 🌟 Warum dieser Vortrag wichtig ist
|
||||
|
|
@ -101,48 +115,48 @@ Der Vortrag zeigt, dass Leadership nichts mit Macht oder Kontrolle zu tun hat, s
|
|||
|
||||
## Vollständiges Transkript
|
||||
|
||||
*Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video.*
|
||||
_Das Transkript wurde automatisch mit Whisper AI erstellt und basiert auf dem YouTube Video._
|
||||
|
||||
This is a man by the name of Captain William Swenson, who recently was awarded the Congressional Medal of Honor for his actions on September 8, 2009. On that day, a column of American and Afghan troops were making their way through a part of Afghanistan to help protect a group of government officials, a group of Afghan government officials who would be meeting with some local village elders. The column came under ambush and was surrounded on three sides. And amongst many other things, Captain Swenson was recognized for running into live fire to rescue the wounded and pull out the dead.
|
||||
This is a man by the name of Captain William Swenson, who recently was awarded the Congressional Medal of Honor for his actions on September 8, 2009. On that day, a column of American and Afghan troops were making their way through a part of Afghanistan to help protect a group of government officials, a group of Afghan government officials who would be meeting with some local village elders. The column came under ambush and was surrounded on three sides. And amongst many other things, Captain Swenson was recognized for running into live fire to rescue the wounded and pull out the dead.
|
||||
|
||||
One of the people he rescued was a sergeant, and he and a comrade were making their way to a Medevac helicopter. And what was remarkable about this day is by sheer coincidence, one of the Medevac medics happened to have a GoPro camera on his helmet and captured the whole scene on camera. It shows Captain Swenson and his comrade bringing this wounded soldier who received a gunshot to the neck. They put him in the helicopter and then you see Captain Swenson bend over and give him a kiss before he turns around to rescue more.
|
||||
One of the people he rescued was a sergeant, and he and a comrade were making their way to a Medevac helicopter. And what was remarkable about this day is by sheer coincidence, one of the Medevac medics happened to have a GoPro camera on his helmet and captured the whole scene on camera. It shows Captain Swenson and his comrade bringing this wounded soldier who received a gunshot to the neck. They put him in the helicopter and then you see Captain Swenson bend over and give him a kiss before he turns around to rescue more.
|
||||
|
||||
I saw this and I thought to myself, where do people like that come from? What is that? That is some deep, deep emotion when you would want to do that. There's a love there. And I wanted to know, why is it that I don't have people that I work with like that? In the military, they give medals to people who are willing to sacrifice themselves so that others may gain. In business, we give bonuses to people who are willing to sacrifice others so that we may gain. Right?
|
||||
I saw this and I thought to myself, where do people like that come from? What is that? That is some deep, deep emotion when you would want to do that. There's a love there. And I wanted to know, why is it that I don't have people that I work with like that? In the military, they give medals to people who are willing to sacrifice themselves so that others may gain. In business, we give bonuses to people who are willing to sacrifice others so that we may gain. Right?
|
||||
|
||||
So I asked myself, where do people like this come from? And my initial conclusion was that they're just better people. That's why they're attracted to the military. These better people are attracted to this concept of service. But that's completely wrong. What I learned is that it's the environment. And if you get the environment right, every single one of us has the capacity to do these remarkable things and more importantly, others have that capacity too.
|
||||
So I asked myself, where do people like this come from? And my initial conclusion was that they're just better people. That's why they're attracted to the military. These better people are attracted to this concept of service. But that's completely wrong. What I learned is that it's the environment. And if you get the environment right, every single one of us has the capacity to do these remarkable things and more importantly, others have that capacity too.
|
||||
|
||||
I've had the great honor of getting to meet some of these who we would call heroes who have put themselves and put their lives at risk to save others. And I asked them, why would you do it? Why did you do it? And they all say the same thing because they would have done it for me. It's this deep sense of trust and cooperation.
|
||||
I've had the great honor of getting to meet some of these who we would call heroes who have put themselves and put their lives at risk to save others. And I asked them, why would you do it? Why did you do it? And they all say the same thing because they would have done it for me. It's this deep sense of trust and cooperation.
|
||||
|
||||
So trust and cooperation are really important here. The problem with concepts of trust and cooperation is that they are feelings. They're not instructions. I can't simply say to you, trust me and you will. I can't simply instruct to people to cooperate and they will. It's not how it works. It's a feeling.
|
||||
So trust and cooperation are really important here. The problem with concepts of trust and cooperation is that they are feelings. They're not instructions. I can't simply say to you, trust me and you will. I can't simply instruct to people to cooperate and they will. It's not how it works. It's a feeling.
|
||||
|
||||
So where does that feeling come from? If you go back 50,000 years to the Paleolithic era to the early days of Homo sapien, what we find is that the world was filled with danger. All of these forces working very, very hard to kill us. Nothing personal. Whether it was the weather, lack of resources, maybe a saber-toothed tiger, all of these things working to reduce our lifespan.
|
||||
So where does that feeling come from? If you go back 50,000 years to the Paleolithic era to the early days of Homo sapien, what we find is that the world was filled with danger. All of these forces working very, very hard to kill us. Nothing personal. Whether it was the weather, lack of resources, maybe a saber-toothed tiger, all of these things working to reduce our lifespan.
|
||||
|
||||
And so we evolved into social animals where we lived together and worked together and what I call a circle of safety inside the tribe where we felt like we belonged. And when we felt safe amongst our own, the natural reaction was trust and cooperation. There are inherent benefits to this. It means I can fall asleep at night and trust that someone from within my tribe will watch for danger. If we don't trust each other, if I don't trust you, that means you won't watch for danger. That's the system of survival.
|
||||
And so we evolved into social animals where we lived together and worked together and what I call a circle of safety inside the tribe where we felt like we belonged. And when we felt safe amongst our own, the natural reaction was trust and cooperation. There are inherent benefits to this. It means I can fall asleep at night and trust that someone from within my tribe will watch for danger. If we don't trust each other, if I don't trust you, that means you won't watch for danger. That's the system of survival.
|
||||
|
||||
The modern day is exactly the same thing. The world is filled with danger. Things that are trying to frustrate our lives or reduce our success, reduce our opportunity for success. It could be the ups and downs of an economy, the uncertainty of the stock market. It could be a new technology that renders your business model obsolete overnight. Or it could be your competition that is sometimes trying to kill you. It's sometimes trying to put you out of business. But at the very minimum, it's working hard to frustrate your growth and steal your business from you. We have no control of these forces. These are a constant and they're not going away.
|
||||
The modern day is exactly the same thing. The world is filled with danger. Things that are trying to frustrate our lives or reduce our success, reduce our opportunity for success. It could be the ups and downs of an economy, the uncertainty of the stock market. It could be a new technology that renders your business model obsolete overnight. Or it could be your competition that is sometimes trying to kill you. It's sometimes trying to put you out of business. But at the very minimum, it's working hard to frustrate your growth and steal your business from you. We have no control of these forces. These are a constant and they're not going away.
|
||||
|
||||
The only variable are the conditions inside the organization. And that's where leadership matters because it's the leader that sets the tone. When a leader makes the choice to put the safety and lives of the people inside the organization first, to sacrifice their comforts and sacrifice, the tangible results so that the people remain and feel safe and feel like they belong, remarkable things happen.
|
||||
The only variable are the conditions inside the organization. And that's where leadership matters because it's the leader that sets the tone. When a leader makes the choice to put the safety and lives of the people inside the organization first, to sacrifice their comforts and sacrifice, the tangible results so that the people remain and feel safe and feel like they belong, remarkable things happen.
|
||||
|
||||
I was flying on a trip and I was witness to an incident where a passenger attempted to board before their number was called. And I watched the gate agent treat this man like he had broken the law, like a criminal. He was yelled at for attempting to board one group too soon. So I said something. I said, why do you have to treat us like cattle? Why can't you treat us like human beings? And this is exactly what she said to me. She said, sir, if I don't follow the rules, I could get in trouble or lose my job. All she was telling me is that she doesn't feel safe. All she was telling me is that she doesn't trust her leaders.
|
||||
I was flying on a trip and I was witness to an incident where a passenger attempted to board before their number was called. And I watched the gate agent treat this man like he had broken the law, like a criminal. He was yelled at for attempting to board one group too soon. So I said something. I said, why do you have to treat us like cattle? Why can't you treat us like human beings? And this is exactly what she said to me. She said, sir, if I don't follow the rules, I could get in trouble or lose my job. All she was telling me is that she doesn't feel safe. All she was telling me is that she doesn't trust her leaders.
|
||||
|
||||
The reason we like flying southwest airlines is not because they necessarily hire better people. It's because they don't fear their leaders. You see, if the conditions are wrong, we're forced to expend our own time and energy to protect ourselves from each other. And that inherently weakens the organization. When we feel safe inside the organization, we will naturally combine our talents and our strengths and work tirelessly to face the dangers outsized and seize the opportunities.
|
||||
The reason we like flying southwest airlines is not because they necessarily hire better people. It's because they don't fear their leaders. You see, if the conditions are wrong, we're forced to expend our own time and energy to protect ourselves from each other. And that inherently weakens the organization. When we feel safe inside the organization, we will naturally combine our talents and our strengths and work tirelessly to face the dangers outsized and seize the opportunities.
|
||||
|
||||
The closest analogy I can give to what a great leader is, it's like being a parent. If you think about what being a great parent is, what do you want? What makes a great parent? We want to give our child opportunities, education, discipline them when necessary, also that they can grow up and achieve more than we could for ourselves. Great leaders want exactly the same thing. They want to provide their people opportunity, education, discipline when necessary, build their self-confidence, give them the opportunity to try and fail, also that they could achieve more than we could ever imagine for ourselves.
|
||||
The closest analogy I can give to what a great leader is, it's like being a parent. If you think about what being a great parent is, what do you want? What makes a great parent? We want to give our child opportunities, education, discipline them when necessary, also that they can grow up and achieve more than we could for ourselves. Great leaders want exactly the same thing. They want to provide their people opportunity, education, discipline when necessary, build their self-confidence, give them the opportunity to try and fail, also that they could achieve more than we could ever imagine for ourselves.
|
||||
|
||||
Charlie Kim, who's the CEO of a company called Next Jump in New York City, a tech company, he makes the point that if you had hard times in your family, would you ever consider laying off one of your children? We would never do it. Then why do we consider laying off people inside our organization? Charlie implemented a policy of lifetime employment. If you get a job at Next Jump, you cannot get fired for performance issues. In fact, if you have issues, they will coach you and they will give you support, just like we would with one of our children who happens to come home with a C from school. It's the complete opposite.
|
||||
Charlie Kim, who's the CEO of a company called Next Jump in New York City, a tech company, he makes the point that if you had hard times in your family, would you ever consider laying off one of your children? We would never do it. Then why do we consider laying off people inside our organization? Charlie implemented a policy of lifetime employment. If you get a job at Next Jump, you cannot get fired for performance issues. In fact, if you have issues, they will coach you and they will give you support, just like we would with one of our children who happens to come home with a C from school. It's the complete opposite.
|
||||
|
||||
This is the reason so many people have such a visceral hatred, discon... sort of anger at some of these banking CEOs with their disproportionate salaries and bonus structures. It's not the numbers. It's that they have violated the very definition of leadership. They have violated this deep-seated social contract. We know that they allowed their people to be sacrificed, so that they could protect their own interests, or worse, they sacrificed their people to protect their own interests. This is what so offends us. Not the numbers.
|
||||
This is the reason so many people have such a visceral hatred, discon... sort of anger at some of these banking CEOs with their disproportionate salaries and bonus structures. It's not the numbers. It's that they have violated the very definition of leadership. They have violated this deep-seated social contract. We know that they allowed their people to be sacrificed, so that they could protect their own interests, or worse, they sacrificed their people to protect their own interests. This is what so offends us. Not the numbers.
|
||||
|
||||
Would anybody be offended if we gave a $150 million bonus to Gandhi? How about a $250 million bonus to Mother Teresa? Do we have an issue with that? None at all. None at all. Great leaders would never sacrifice the people to save the numbers. They would sooner sacrifice the numbers to save the people.
|
||||
Would anybody be offended if we gave a $150 million bonus to Gandhi? How about a $250 million bonus to Mother Teresa? Do we have an issue with that? None at all. None at all. Great leaders would never sacrifice the people to save the numbers. They would sooner sacrifice the numbers to save the people.
|
||||
|
||||
Bob Chapman, who runs a large manufacturing company in the Midwest called Barry Waymiller in 2008, was hit very hard by the recession, and they lost 30% of their orders overnight. Now, in a large manufacturing company, this is a big deal, and they could no longer afford their labor pool. They needed to save $10 million, so like so many companies today, the board got together and discussed layoffs. And Bob refused.
|
||||
Bob Chapman, who runs a large manufacturing company in the Midwest called Barry Waymiller in 2008, was hit very hard by the recession, and they lost 30% of their orders overnight. Now, in a large manufacturing company, this is a big deal, and they could no longer afford their labor pool. They needed to save $10 million, so like so many companies today, the board got together and discussed layoffs. And Bob refused.
|
||||
|
||||
You see, Bob doesn't believe in head counts. Bob believes in heart counts, and it's much more difficult to simply reduce the heart count. And so they came up with a furlough program. Every employee from Secretary to CEO was required to take four weeks of unpaid vacation. They could take it any time they wanted, and they did not have to take it consecutively. But it was how Bob announced the program that mattered so much. He said, it's better that we should all suffer a little than any of us should have to suffer a lot. And morale went up. They saved $20 million. And most importantly, as would be expected, when the people feel safe and protected by the leadership in the organization, the natural reaction is to trust and cooperate. And quite spontaneously, nobody expected. People started trading with each other. Those who could afford it more would trade with those who could afford it less. People would take five weeks so that somebody else only had to take three.
|
||||
You see, Bob doesn't believe in head counts. Bob believes in heart counts, and it's much more difficult to simply reduce the heart count. And so they came up with a furlough program. Every employee from Secretary to CEO was required to take four weeks of unpaid vacation. They could take it any time they wanted, and they did not have to take it consecutively. But it was how Bob announced the program that mattered so much. He said, it's better that we should all suffer a little than any of us should have to suffer a lot. And morale went up. They saved $20 million. And most importantly, as would be expected, when the people feel safe and protected by the leadership in the organization, the natural reaction is to trust and cooperate. And quite spontaneously, nobody expected. People started trading with each other. Those who could afford it more would trade with those who could afford it less. People would take five weeks so that somebody else only had to take three.
|
||||
|
||||
Leadership is a choice. It is not a rank. I know many people at the senior most levels of organizations who are absolutely not leaders. They are authorities. And we do what they say because they have authority over us. But we would not follow them. And I know many people who are at the bottoms of organizations who have no authority and they are absolutely leaders. And this is because they have chosen to look after the person to the left of them. And they have chosen to look after the person to the right of them. This is what a leader is.
|
||||
Leadership is a choice. It is not a rank. I know many people at the senior most levels of organizations who are absolutely not leaders. They are authorities. And we do what they say because they have authority over us. But we would not follow them. And I know many people who are at the bottoms of organizations who have no authority and they are absolutely leaders. And this is because they have chosen to look after the person to the left of them. And they have chosen to look after the person to the right of them. This is what a leader is.
|
||||
|
||||
I heard a story of some Marines who were out in theater. And as is the Marine Custom, the officer ate last. And he let his men eat first. And when they were done, there was no food left for him. And when they went back out in the field, his men brought him some of their food so that he made. Because that's what happens.
|
||||
I heard a story of some Marines who were out in theater. And as is the Marine Custom, the officer ate last. And he let his men eat first. And when they were done, there was no food left for him. And when they went back out in the field, his men brought him some of their food so that he made. Because that's what happens.
|
||||
|
||||
We call them leaders because they go first. We call them leaders because they take the risk before anybody else does. We call them leaders because they will choose to sacrifice so that their people may be safe and protected and so that their people may gain. And when we do, the natural response is that our people will sacrifice for us. They will give us their blood and sweat and tears to see that their leader's vision comes to life. And when we ask them, why would you do that? Why would you give your blood and sweat and tears for that person? They all say the same thing because they would have done it for me. And isn't that the organization we would all like to work in?
|
||||
We call them leaders because they go first. We call them leaders because they take the risk before anybody else does. We call them leaders because they will choose to sacrifice so that their people may be safe and protected and so that their people may gain. And when we do, the natural response is that our people will sacrifice for us. They will give us their blood and sweat and tears to see that their leader's vision comes to life. And when we ask them, why would you do that? Why would you give your blood and sweat and tears for that person? They all say the same thing because they would have done it for me. And isn't that the organization we would all like to work in?
|
||||
|
||||
Thank you very much. Thank you. Thank you. Thank you.
|
||||
Thank you very much. Thank you. Thank you. Thank you.
|
||||
|
|
|
|||
2
apps/wisekeep/apps/landing/src/env.d.ts
vendored
2
apps/wisekeep/apps/landing/src/env.d.ts
vendored
|
|
@ -1 +1 @@
|
|||
/// <reference path="../.astro/types.d.ts" />
|
||||
/// <reference path="../.astro/types.d.ts" />
|
||||
|
|
|
|||
|
|
@ -1,48 +1,66 @@
|
|||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="YouTube Transcriber Admin" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100">
|
||||
<nav class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/admin" class="flex items-center space-x-2">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span class="text-xl font-bold text-white">Admin Panel</span>
|
||||
</a>
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<a href="/admin" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Dashboard</a>
|
||||
<a href="/admin/playlists" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Playlists</a>
|
||||
<a href="/admin/transcripts" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Transkripte</a>
|
||||
<a href="/admin/settings" class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium">Einstellungen</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="text-blue-400 hover:text-blue-300 text-sm">→ Zur öffentlichen Seite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="YouTube Transcriber Admin" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body class="bg-gray-900 text-gray-100">
|
||||
<nav class="bg-gray-800 border-b border-gray-700">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex items-center justify-between h-16">
|
||||
<div class="flex items-center">
|
||||
<a href="/admin" class="flex items-center space-x-2">
|
||||
<span class="text-2xl">🎥</span>
|
||||
<span class="text-xl font-bold text-white">Admin Panel</span>
|
||||
</a>
|
||||
<div class="ml-10 flex items-baseline space-x-4">
|
||||
<a
|
||||
href="/admin"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>Dashboard</a
|
||||
>
|
||||
<a
|
||||
href="/admin/playlists"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>Playlists</a
|
||||
>
|
||||
<a
|
||||
href="/admin/transcripts"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>Transkripte</a
|
||||
>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
class="text-gray-300 hover:text-white px-3 py-2 rounded-md text-sm font-medium"
|
||||
>Einstellungen</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-4">
|
||||
<a href="/" class="text-blue-400 hover:text-blue-300 text-sm"
|
||||
>→ Zur öffentlichen Seite</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<slot />
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
@import '../styles/global.css';
|
||||
</style>
|
||||
@import '../styles/global.css';
|
||||
</style>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,108 +1,110 @@
|
|||
---
|
||||
export interface Props {
|
||||
title: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
const { title } = Astro.props;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="YouTube Wisdom Library - Transkribierte Vorträge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-container">
|
||||
<a href="/" class="logo">🎥 Wisdom Library</a>
|
||||
<div class="nav-links">
|
||||
<a href="/talks">Vorträge</a>
|
||||
<a href="/speakers">Sprecher</a>
|
||||
<a href="/categories">Kategorien</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<slot />
|
||||
<footer>
|
||||
<p>© 2024 YouTube Wisdom Library. Powered by OpenAI Whisper.</p>
|
||||
</footer>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="description" content="YouTube Wisdom Library - Transkribierte Vorträge" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="generator" content={Astro.generator} />
|
||||
<title>{title}</title>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<div class="nav-container">
|
||||
<a href="/" class="logo">🎥 Wisdom Library</a>
|
||||
<div class="nav-links">
|
||||
<a href="/talks">Vorträge</a>
|
||||
<a href="/speakers">Sprecher</a>
|
||||
<a href="/categories">Kategorien</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<slot />
|
||||
<footer>
|
||||
<p>© 2024 YouTube Wisdom Library. Powered by OpenAI Whisper.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
<style is:global>
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #f8f9fa;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 4rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
html {
|
||||
font-family: system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
nav {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
footer {
|
||||
background: #f8f9fa;
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
margin-top: 4rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -7,173 +7,183 @@ import '../../styles/themes.css';
|
|||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="ocean">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Dashboard - YouTube Transcriber</title>
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--theme-border), 0.2);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
.stat-label {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-top: 5px;
|
||||
}
|
||||
.quick-actions {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--theme-border), 0.2);
|
||||
}
|
||||
.input-field {
|
||||
width: 70%;
|
||||
padding: 10px;
|
||||
background: rgb(var(--theme-background));
|
||||
color: rgb(var(--theme-text));
|
||||
border: 1px solid rgba(var(--theme-border), 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: rgb(var(--theme-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<div class="container">
|
||||
<!-- Admin sub-navigation -->
|
||||
<div class="bg-theme-card border border-theme-border/20 rounded-lg p-4 mb-8">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="/admin" class="text-theme-primary font-semibold">Dashboard</a>
|
||||
<a href="/admin/playlists" class="text-theme-text-muted hover:text-theme-primary transition-colors">Playlists</a>
|
||||
<a href="/admin/transcripts" class="text-theme-text-muted hover:text-theme-primary transition-colors">Transkripte</a>
|
||||
<a href="/admin/settings" class="text-theme-text-muted hover:text-theme-primary transition-colors">Einstellungen</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold text-theme-primary mb-8">🎥 Admin Dashboard</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="transcripts">-</div>
|
||||
<div class="stat-label">Transkripte</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="active">-</div>
|
||||
<div class="stat-label">Aktive Jobs</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="playlists">-</div>
|
||||
<div class="stat-label">Playlists</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="size">-</div>
|
||||
<div class="stat-label">Speicher</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-theme-text mb-4">Quick Actions</h2>
|
||||
<div class="quick-actions">
|
||||
<input type="text" id="url" placeholder="YouTube URL eingeben..." class="input-field">
|
||||
<button onclick="startTranscription()" class="btn-primary">Transkribieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
<script>
|
||||
// Load stats from API
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/stats');
|
||||
const data = await response.json();
|
||||
document.getElementById('transcripts').textContent = data.total_transcripts;
|
||||
document.getElementById('active').textContent = data.active_jobs;
|
||||
document.getElementById('size').textContent = data.total_size_mb + ' MB';
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load playlists count
|
||||
async function loadPlaylists() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/playlists');
|
||||
const data = await response.json();
|
||||
document.getElementById('playlists').textContent = data.length;
|
||||
} catch (error) {
|
||||
console.error('Error loading playlists:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start transcription
|
||||
async function startTranscription() {
|
||||
const url = document.getElementById('url').value;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
model: 'base',
|
||||
language: 'de'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Transkription gestartet!');
|
||||
document.getElementById('url').value = '';
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Fehler beim Starten der Transkription');
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
loadStats();
|
||||
loadPlaylists();
|
||||
|
||||
// Refresh every 5 seconds
|
||||
setInterval(loadStats, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Admin Dashboard - YouTube Transcriber</title>
|
||||
<style>
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 20px;
|
||||
margin: 30px 0;
|
||||
}
|
||||
.stat-card {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--theme-border), 0.2);
|
||||
}
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
.stat-label {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-top: 5px;
|
||||
}
|
||||
.quick-actions {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(var(--theme-border), 0.2);
|
||||
}
|
||||
.input-field {
|
||||
width: 70%;
|
||||
padding: 10px;
|
||||
background: rgb(var(--theme-background));
|
||||
color: rgb(var(--theme-text));
|
||||
border: 1px solid rgba(var(--theme-border), 0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.btn-primary {
|
||||
padding: 10px 20px;
|
||||
background: rgb(var(--theme-primary));
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
margin-left: 10px;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<div class="container">
|
||||
<!-- Admin sub-navigation -->
|
||||
<div class="bg-theme-card border border-theme-border/20 rounded-lg p-4 mb-8">
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<a href="/admin" class="text-theme-primary font-semibold">Dashboard</a>
|
||||
<a
|
||||
href="/admin/playlists"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors">Playlists</a
|
||||
>
|
||||
<a
|
||||
href="/admin/transcripts"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors">Transkripte</a
|
||||
>
|
||||
<a
|
||||
href="/admin/settings"
|
||||
class="text-theme-text-muted hover:text-theme-primary transition-colors"
|
||||
>Einstellungen</a
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 class="text-4xl font-bold text-theme-primary mb-8">🎥 Admin Dashboard</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="transcripts">-</div>
|
||||
<div class="stat-label">Transkripte</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="active">-</div>
|
||||
<div class="stat-label">Aktive Jobs</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="playlists">-</div>
|
||||
<div class="stat-label">Playlists</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="size">-</div>
|
||||
<div class="stat-label">Speicher</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 class="text-2xl font-semibold text-theme-text mb-4">Quick Actions</h2>
|
||||
<div class="quick-actions">
|
||||
<input type="text" id="url" placeholder="YouTube URL eingeben..." class="input-field" />
|
||||
<button onclick="startTranscription()" class="btn-primary">Transkribieren</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
|
||||
<script>
|
||||
// Load stats from API
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/stats');
|
||||
const data = await response.json();
|
||||
document.getElementById('transcripts').textContent = data.total_transcripts;
|
||||
document.getElementById('active').textContent = data.active_jobs;
|
||||
document.getElementById('size').textContent = data.total_size_mb + ' MB';
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Load playlists count
|
||||
async function loadPlaylists() {
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/playlists');
|
||||
const data = await response.json();
|
||||
document.getElementById('playlists').textContent = data.length;
|
||||
} catch (error) {
|
||||
console.error('Error loading playlists:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Start transcription
|
||||
async function startTranscription() {
|
||||
const url = document.getElementById('url').value;
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:8000/api/transcribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url: url,
|
||||
model: 'base',
|
||||
language: 'de',
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Transkription gestartet!');
|
||||
document.getElementById('url').value = '';
|
||||
loadStats();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
alert('Fehler beim Starten der Transkription');
|
||||
}
|
||||
}
|
||||
|
||||
// Load data on page load
|
||||
loadStats();
|
||||
loadPlaylists();
|
||||
|
||||
// Refresh every 5 seconds
|
||||
setInterval(loadStats, 5000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -4,32 +4,38 @@ import SearchableContentList from '../components/SearchableContentList';
|
|||
import Footer from '../components/Footer.astro';
|
||||
---
|
||||
|
||||
<BaseLayout title="YouTube Wisdom Library" description="Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer verfügbar.">
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Minimalist Hero Section -->
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-6">
|
||||
<span class="text-gradient">YouTube Wisdom Library</span>
|
||||
</h1>
|
||||
<p class="text-xl text-theme-text-muted max-w-3xl mx-auto">
|
||||
Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
<BaseLayout
|
||||
title="YouTube Wisdom Library"
|
||||
description="Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer verfügbar."
|
||||
>
|
||||
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<!-- Minimalist Hero Section -->
|
||||
<div class="text-center mb-16">
|
||||
<h1 class="text-5xl md:text-6xl font-bold mb-6">
|
||||
<span class="text-gradient">YouTube Wisdom Library</span>
|
||||
</h1>
|
||||
<p class="text-xl text-theme-text-muted max-w-3xl mx-auto">
|
||||
Transkribierte Vorträge von führenden Denkern - durchsuchbar, aufbereitet und immer
|
||||
verfügbar.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Searchable Content Cards with integrated search -->
|
||||
<SearchableContentList client:load />
|
||||
</main>
|
||||
<!-- Searchable Content Cards with integrated search -->
|
||||
<SearchableContentList client:load />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg,
|
||||
rgb(var(--theme-primary)) 0%,
|
||||
rgb(var(--theme-secondary)) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
<Footer />
|
||||
|
||||
<style>
|
||||
.text-gradient {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgb(var(--theme-primary)) 0%,
|
||||
rgb(var(--theme-secondary)) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
</style>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
|
|
@ -12,380 +12,377 @@ const currentPath = Astro.url.pathname;
|
|||
|
||||
// Rory Sutherland's data
|
||||
const speakerData = {
|
||||
name: "Rory Sutherland",
|
||||
title: "Vice Chairman",
|
||||
company: "Ogilvy UK",
|
||||
bio: "Rory Sutherland ist einer der führenden Denker im Bereich Behavioral Economics und Marketing Psychology. Als Vice Chairman von Ogilvy UK und Gründer der Behavioral Science Practice hat er revolutionäre Ansätze entwickelt, wie psychologische Erkenntnisse in Marketing und Business angewendet werden können. Seine TED Talks über die Macht der Wahrnehmung und psychologische Lösungen haben Millionen inspiriert.",
|
||||
twitter: "rorysutherland",
|
||||
linkedin: "rorysutherland",
|
||||
website: "https://www.ogilvy.com"
|
||||
name: 'Rory Sutherland',
|
||||
title: 'Vice Chairman',
|
||||
company: 'Ogilvy UK',
|
||||
bio: 'Rory Sutherland ist einer der führenden Denker im Bereich Behavioral Economics und Marketing Psychology. Als Vice Chairman von Ogilvy UK und Gründer der Behavioral Science Practice hat er revolutionäre Ansätze entwickelt, wie psychologische Erkenntnisse in Marketing und Business angewendet werden können. Seine TED Talks über die Macht der Wahrnehmung und psychologische Lösungen haben Millionen inspiriert.',
|
||||
twitter: 'rorysutherland',
|
||||
linkedin: 'rorysutherland',
|
||||
website: 'https://www.ogilvy.com',
|
||||
};
|
||||
|
||||
const statsData = {
|
||||
totalTalks: 12,
|
||||
totalDuration: "8.5",
|
||||
totalViews: "15M+",
|
||||
topTopics: ["Behavioral Economics", "Marketing Psychology", "Innovation", "Perception"],
|
||||
firstTalk: "2009",
|
||||
latestTalk: "2023"
|
||||
totalTalks: 12,
|
||||
totalDuration: '8.5',
|
||||
totalViews: '15M+',
|
||||
topTopics: ['Behavioral Economics', 'Marketing Psychology', 'Innovation', 'Perception'],
|
||||
firstTalk: '2009',
|
||||
latestTalk: '2023',
|
||||
};
|
||||
|
||||
const quotes = [
|
||||
{
|
||||
text: "The circumstances of our lives may matter less than how we see them.",
|
||||
talk: "Perspective is Everything",
|
||||
context: "Über die Macht der Wahrnehmung in unserem täglichen Leben"
|
||||
},
|
||||
{
|
||||
text: "When you can't change the reality, change the perception of reality.",
|
||||
talk: "Perspective is Everything",
|
||||
context: "Wie psychologische Lösungen oft effektiver sind als technische"
|
||||
},
|
||||
{
|
||||
text: "A flower is a weed with an advertising budget.",
|
||||
talk: "Life Lessons from an Ad Man",
|
||||
context: "Über die Rolle von Marketing in der Wertschöpfung"
|
||||
},
|
||||
{
|
||||
text: "The opposite of a good idea can also be a good idea.",
|
||||
talk: "Sweat the Small Stuff",
|
||||
context: "Warum kontraintuitive Ansätze oft die besten Lösungen bieten"
|
||||
},
|
||||
{
|
||||
text: "Engineers make assumptions about human rationality that are simply not true.",
|
||||
talk: "Perspective is Everything",
|
||||
context: "Die Kluft zwischen technischen und psychologischen Lösungen"
|
||||
},
|
||||
{
|
||||
text: "The placebo effect is not a trick, it's a deep feature of how humans work.",
|
||||
talk: "Life Lessons from an Ad Man",
|
||||
context: "Über die Rolle von Erwartungen in der menschlichen Erfahrung"
|
||||
},
|
||||
{
|
||||
text: "Google is what happens when engineers run marketing. Uber is what happens when marketers run engineering.",
|
||||
talk: "The Psychology of Digital Marketing",
|
||||
context: "Über die unterschiedlichen Ansätze in der Tech-Industrie"
|
||||
},
|
||||
{
|
||||
text: "We don't value things; we value their meaning. What they are is determined by the laws of physics, but what they mean is determined by the laws of psychology.",
|
||||
talk: "Perspective is Everything",
|
||||
context: "Die fundamentale Rolle der Psychologie in der Wertwahrnehmung"
|
||||
}
|
||||
{
|
||||
text: 'The circumstances of our lives may matter less than how we see them.',
|
||||
talk: 'Perspective is Everything',
|
||||
context: 'Über die Macht der Wahrnehmung in unserem täglichen Leben',
|
||||
},
|
||||
{
|
||||
text: "When you can't change the reality, change the perception of reality.",
|
||||
talk: 'Perspective is Everything',
|
||||
context: 'Wie psychologische Lösungen oft effektiver sind als technische',
|
||||
},
|
||||
{
|
||||
text: 'A flower is a weed with an advertising budget.',
|
||||
talk: 'Life Lessons from an Ad Man',
|
||||
context: 'Über die Rolle von Marketing in der Wertschöpfung',
|
||||
},
|
||||
{
|
||||
text: 'The opposite of a good idea can also be a good idea.',
|
||||
talk: 'Sweat the Small Stuff',
|
||||
context: 'Warum kontraintuitive Ansätze oft die besten Lösungen bieten',
|
||||
},
|
||||
{
|
||||
text: 'Engineers make assumptions about human rationality that are simply not true.',
|
||||
talk: 'Perspective is Everything',
|
||||
context: 'Die Kluft zwischen technischen und psychologischen Lösungen',
|
||||
},
|
||||
{
|
||||
text: "The placebo effect is not a trick, it's a deep feature of how humans work.",
|
||||
talk: 'Life Lessons from an Ad Man',
|
||||
context: 'Über die Rolle von Erwartungen in der menschlichen Erfahrung',
|
||||
},
|
||||
{
|
||||
text: 'Google is what happens when engineers run marketing. Uber is what happens when marketers run engineering.',
|
||||
talk: 'The Psychology of Digital Marketing',
|
||||
context: 'Über die unterschiedlichen Ansätze in der Tech-Industrie',
|
||||
},
|
||||
{
|
||||
text: "We don't value things; we value their meaning. What they are is determined by the laws of physics, but what they mean is determined by the laws of psychology.",
|
||||
talk: 'Perspective is Everything',
|
||||
context: 'Die fundamentale Rolle der Psychologie in der Wertwahrnehmung',
|
||||
},
|
||||
];
|
||||
|
||||
const talks = [
|
||||
{
|
||||
id: "1",
|
||||
title: "Perspective is Everything: The Psychology of Reframing",
|
||||
date: "März 2023",
|
||||
duration: "18:24",
|
||||
description: "Rory Sutherland erforscht, wie kleine Änderungen in der Perspektive massive Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können.",
|
||||
tags: ["Behavioral Economics", "Psychology", "Marketing", "Innovation"],
|
||||
url: "/talks/rory-sutherland-perspective-is-everything",
|
||||
views: "3.2M"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Life Lessons from an Ad Man",
|
||||
date: "Juli 2021",
|
||||
duration: "16:40",
|
||||
description: "Advertising adds value to a product by changing our perception, rather than the product itself. Rory Sutherland makes the daring assertion that a change in perceived value can be just as satisfying as what we consider real value.",
|
||||
tags: ["Advertising", "Value Creation", "Psychology", "Business"],
|
||||
url: "/talks/life-lessons-from-an-ad-man",
|
||||
views: "4.8M"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Sweat the Small Stuff",
|
||||
date: "Dezember 2020",
|
||||
duration: "12:30",
|
||||
description: "Rory Sutherland erklärt, warum die Details oft wichtiger sind als die großen Ideen und wie kleine psychologische Interventionen große Wirkungen haben können.",
|
||||
tags: ["Details", "UX Design", "Psychology", "Innovation"],
|
||||
url: "/talks/sweat-the-small-stuff",
|
||||
views: "2.1M"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "The Psychology of Digital Marketing",
|
||||
date: "September 2019",
|
||||
duration: "21:15",
|
||||
description: "Eine tiefgehende Analyse, wie digitales Marketing die menschliche Psychologie nutzt und manchmal missbraucht.",
|
||||
tags: ["Digital Marketing", "Technology", "Ethics", "Psychology"],
|
||||
url: "/talks/psychology-of-digital-marketing",
|
||||
views: "1.9M"
|
||||
},
|
||||
{
|
||||
id: "5",
|
||||
title: "Why Efficiency is Dangerous",
|
||||
date: "Mai 2018",
|
||||
duration: "19:45",
|
||||
description: "Rory argumentiert, dass unsere Obsession mit Effizienz oft zu schlechteren Ergebnissen führt als scheinbar ineffiziente Lösungen.",
|
||||
tags: ["Efficiency", "Innovation", "Business Strategy", "Paradox"],
|
||||
url: "/talks/why-efficiency-is-dangerous",
|
||||
views: "1.5M"
|
||||
},
|
||||
{
|
||||
id: "6",
|
||||
title: "The Power of Costly Signaling",
|
||||
date: "Januar 2017",
|
||||
duration: "14:20",
|
||||
description: "Warum teure und aufwendige Gesten oft effektiver sind als effiziente Lösungen in der Kommunikation.",
|
||||
tags: ["Signaling", "Communication", "Economics", "Evolution"],
|
||||
url: "/talks/power-of-costly-signaling",
|
||||
views: "980K"
|
||||
}
|
||||
{
|
||||
id: '1',
|
||||
title: 'Perspective is Everything: The Psychology of Reframing',
|
||||
date: 'März 2023',
|
||||
duration: '18:24',
|
||||
description:
|
||||
'Rory Sutherland erforscht, wie kleine Änderungen in der Perspektive massive Auswirkungen auf unser Verhalten und unsere Entscheidungen haben können.',
|
||||
tags: ['Behavioral Economics', 'Psychology', 'Marketing', 'Innovation'],
|
||||
url: '/talks/rory-sutherland-perspective-is-everything',
|
||||
views: '3.2M',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Life Lessons from an Ad Man',
|
||||
date: 'Juli 2021',
|
||||
duration: '16:40',
|
||||
description:
|
||||
'Advertising adds value to a product by changing our perception, rather than the product itself. Rory Sutherland makes the daring assertion that a change in perceived value can be just as satisfying as what we consider real value.',
|
||||
tags: ['Advertising', 'Value Creation', 'Psychology', 'Business'],
|
||||
url: '/talks/life-lessons-from-an-ad-man',
|
||||
views: '4.8M',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Sweat the Small Stuff',
|
||||
date: 'Dezember 2020',
|
||||
duration: '12:30',
|
||||
description:
|
||||
'Rory Sutherland erklärt, warum die Details oft wichtiger sind als die großen Ideen und wie kleine psychologische Interventionen große Wirkungen haben können.',
|
||||
tags: ['Details', 'UX Design', 'Psychology', 'Innovation'],
|
||||
url: '/talks/sweat-the-small-stuff',
|
||||
views: '2.1M',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'The Psychology of Digital Marketing',
|
||||
date: 'September 2019',
|
||||
duration: '21:15',
|
||||
description:
|
||||
'Eine tiefgehende Analyse, wie digitales Marketing die menschliche Psychologie nutzt und manchmal missbraucht.',
|
||||
tags: ['Digital Marketing', 'Technology', 'Ethics', 'Psychology'],
|
||||
url: '/talks/psychology-of-digital-marketing',
|
||||
views: '1.9M',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Why Efficiency is Dangerous',
|
||||
date: 'Mai 2018',
|
||||
duration: '19:45',
|
||||
description:
|
||||
'Rory argumentiert, dass unsere Obsession mit Effizienz oft zu schlechteren Ergebnissen führt als scheinbar ineffiziente Lösungen.',
|
||||
tags: ['Efficiency', 'Innovation', 'Business Strategy', 'Paradox'],
|
||||
url: '/talks/why-efficiency-is-dangerous',
|
||||
views: '1.5M',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'The Power of Costly Signaling',
|
||||
date: 'Januar 2017',
|
||||
duration: '14:20',
|
||||
description:
|
||||
'Warum teure und aufwendige Gesten oft effektiver sind als effiziente Lösungen in der Kommunikation.',
|
||||
tags: ['Signaling', 'Communication', 'Economics', 'Evolution'],
|
||||
url: '/talks/power-of-costly-signaling',
|
||||
views: '980K',
|
||||
},
|
||||
];
|
||||
|
||||
// Key Concepts Section Data
|
||||
const keyConcepts = [
|
||||
{
|
||||
title: "Psychologische vs. Technische Lösungen",
|
||||
description: "Oft sind psychologische Interventionen billiger und effektiver als technische Verbesserungen. Das Aufzugspiegel-Beispiel zeigt, wie Wahrnehmung wichtiger sein kann als Realität.",
|
||||
icon: "🧠"
|
||||
},
|
||||
{
|
||||
title: "Der Wert der Wahrnehmung",
|
||||
description: "Wir bewerten nicht Dinge, sondern ihre Bedeutung. Was etwas ist, wird durch Physik bestimmt, was es bedeutet durch Psychologie.",
|
||||
icon: "👁️"
|
||||
},
|
||||
{
|
||||
title: "Placebo-Effekt im Marketing",
|
||||
description: "Der Placebo-Effekt ist kein Trick, sondern ein fundamentales Merkmal menschlicher Psychologie, das in allen Lebensbereichen wirkt.",
|
||||
icon: "💊"
|
||||
},
|
||||
{
|
||||
title: "Costly Signaling Theory",
|
||||
description: "Aufwendige und teure Gesten sind oft effektiver in der Kommunikation, weil sie Engagement und Commitment signalisieren.",
|
||||
icon: "💎"
|
||||
}
|
||||
{
|
||||
title: 'Psychologische vs. Technische Lösungen',
|
||||
description:
|
||||
'Oft sind psychologische Interventionen billiger und effektiver als technische Verbesserungen. Das Aufzugspiegel-Beispiel zeigt, wie Wahrnehmung wichtiger sein kann als Realität.',
|
||||
icon: '🧠',
|
||||
},
|
||||
{
|
||||
title: 'Der Wert der Wahrnehmung',
|
||||
description:
|
||||
'Wir bewerten nicht Dinge, sondern ihre Bedeutung. Was etwas ist, wird durch Physik bestimmt, was es bedeutet durch Psychologie.',
|
||||
icon: '👁️',
|
||||
},
|
||||
{
|
||||
title: 'Placebo-Effekt im Marketing',
|
||||
description:
|
||||
'Der Placebo-Effekt ist kein Trick, sondern ein fundamentales Merkmal menschlicher Psychologie, das in allen Lebensbereichen wirkt.',
|
||||
icon: '💊',
|
||||
},
|
||||
{
|
||||
title: 'Costly Signaling Theory',
|
||||
description:
|
||||
'Aufwendige und teure Gesten sind oft effektiver in der Kommunikation, weil sie Engagement und Commitment signalisieren.',
|
||||
icon: '💎',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="ocean">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rory Sutherland - Speaker Profile | YouTube Wisdom Library</title>
|
||||
<meta name="description" content="Entdecken Sie alle Vorträge und Insights von Rory Sutherland über Behavioral Economics, Marketing Psychology und Innovation.">
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<SpeakerHero {...speakerData} />
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<SpeakerStats {...statsData} />
|
||||
|
||||
<!-- Key Concepts Section -->
|
||||
<section class="py-12 bg-theme-card/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">
|
||||
Zentrale Konzepte & Ideen
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{keyConcepts.map(concept => (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-4xl">{concept.icon}</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-2">
|
||||
{concept.title}
|
||||
</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
{concept.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Talks Grid -->
|
||||
<TalkGrid talks={talks} />
|
||||
|
||||
<!-- Quotes Collection -->
|
||||
<div class="bg-theme-card/30">
|
||||
<QuoteCollection quotes={quotes} speakerName="Rory Sutherland" />
|
||||
</div>
|
||||
|
||||
<!-- Content Collections Navigation -->
|
||||
<section class="py-12 bg-theme-primary/5">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8 text-center">
|
||||
Alle Inhalte von {speakerData.name}
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-12">
|
||||
<!-- Transcripts Collection -->
|
||||
<div class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">📜</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">
|
||||
Alle Transkripte
|
||||
</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
{statsData.totalTalks} komplette Transkripte auf einer Seite. Durchsuchbar, filterbar und komplett kopierbar.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/transcripts"
|
||||
class="inline-flex items-center gap-2 bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>📋</span> Zu den Transkripten
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">
|
||||
Geschätzte Lesezeit: {statsData.totalDuration} Stunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analyses Collection -->
|
||||
<div class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-secondary/30 transition-all duration-300 group">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">📊</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">
|
||||
Alle Analysen & Insights
|
||||
</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
Zusammenfassungen, Key Insights, Zitate und Takeaways - alles kompakt und kopierbar.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses"
|
||||
class="inline-flex items-center gap-2 bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>💡</span> Zu den Analysen
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">
|
||||
Insights • Quotes • Takeaways • Reflexionen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete Collection -->
|
||||
<div class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-accent/30 transition-all duration-300 group">
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">🔄</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">
|
||||
Komplette Sammlung
|
||||
</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
Analysen + Transkripte kombiniert. Side-by-side oder einzeln anzeigen - perfekt zum Lernen.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/all"
|
||||
class="inline-flex items-center gap-2 bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>🎯</span> Komplette Sammlung
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">
|
||||
Side-by-Side • Alles zusammen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-theme-text mb-4">
|
||||
🎯 Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="/speakers/rory-sutherland/transcripts?preset=recent"
|
||||
class="px-4 py-2 bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all text-sm font-medium"
|
||||
>
|
||||
🕒 Neueste Transkripte
|
||||
</a>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses?content=insights"
|
||||
class="px-4 py-2 bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all text-sm font-medium"
|
||||
>
|
||||
💡 Nur Key Insights
|
||||
</a>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses?content=quotes"
|
||||
class="px-4 py-2 bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all text-sm font-medium"
|
||||
>
|
||||
💬 Nur Zitate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Rory Sutherland - Speaker Profile | YouTube Wisdom Library</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Entdecken Sie alle Vorträge und Insights von Rory Sutherland über Behavioral Economics, Marketing Psychology und Innovation."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<!-- Related Resources -->
|
||||
<section class="py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">
|
||||
Weitere Ressourcen
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<!-- Books -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📚</span> Bücher
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Alchemy: The Dark Art and Curious Science of Creating Magic in Brands
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Transport for Humans (Co-Author)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Articles -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📝</span> Artikel & Essays
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Spectator Column
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Marketing Week Articles
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Podcasts -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>🎙️</span> Podcast Auftritte
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Tim Ferriss Show
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Knowledge Project
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
<!-- Hero Section -->
|
||||
<SpeakerHero {...speakerData} />
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<SpeakerStats {...statsData} />
|
||||
|
||||
<!-- Key Concepts Section -->
|
||||
<section class="py-12 bg-theme-card/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">Zentrale Konzepte & Ideen</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{
|
||||
keyConcepts.map((concept) => (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-4xl">{concept.icon}</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-2">{concept.title}</h3>
|
||||
<p class="text-theme-text-muted">{concept.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Talks Grid -->
|
||||
<TalkGrid talks={talks} />
|
||||
|
||||
<!-- Quotes Collection -->
|
||||
<div class="bg-theme-card/30">
|
||||
<QuoteCollection quotes={quotes} speakerName="Rory Sutherland" />
|
||||
</div>
|
||||
|
||||
<!-- Content Collections Navigation -->
|
||||
<section class="py-12 bg-theme-primary/5">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8 text-center">
|
||||
Alle Inhalte von {speakerData.name}
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-8 mb-12">
|
||||
<!-- Transcripts Collection -->
|
||||
<div
|
||||
class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">📜</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">Alle Transkripte</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
{statsData.totalTalks} komplette Transkripte auf einer Seite. Durchsuchbar, filterbar
|
||||
und komplett kopierbar.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/transcripts"
|
||||
class="inline-flex items-center gap-2 bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>📋</span> Zu den Transkripten
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">
|
||||
Geschätzte Lesezeit: {statsData.totalDuration} Stunden
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analyses Collection -->
|
||||
<div
|
||||
class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-secondary/30 transition-all duration-300 group"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">📊</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">Alle Analysen & Insights</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
Zusammenfassungen, Key Insights, Zitate und Takeaways - alles kompakt und kopierbar.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses"
|
||||
class="inline-flex items-center gap-2 bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>💡</span> Zu den Analysen
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">
|
||||
Insights • Quotes • Takeaways • Reflexionen
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Complete Collection -->
|
||||
<div
|
||||
class="bg-theme-card rounded-2xl p-8 border border-theme-border/20 hover:border-theme-accent/30 transition-all duration-300 group"
|
||||
>
|
||||
<div class="text-center">
|
||||
<div class="text-6xl mb-4 group-hover:scale-110 transition-transform">🔄</div>
|
||||
<h3 class="text-2xl font-bold text-theme-text mb-4">Komplette Sammlung</h3>
|
||||
<p class="text-theme-text-muted mb-6">
|
||||
Analysen + Transkripte kombiniert. Side-by-side oder einzeln anzeigen - perfekt zum
|
||||
Lernen.
|
||||
</p>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/all"
|
||||
class="inline-flex items-center gap-2 bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold"
|
||||
>
|
||||
<span>🎯</span> Komplette Sammlung
|
||||
</a>
|
||||
<div class="mt-4 text-sm text-theme-text-muted">Side-by-Side • Alles zusammen</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="text-center">
|
||||
<h3 class="text-lg font-semibold text-theme-text mb-4">🎯 Quick Actions</h3>
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
<a
|
||||
href="/speakers/rory-sutherland/transcripts?preset=recent"
|
||||
class="px-4 py-2 bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all text-sm font-medium"
|
||||
>
|
||||
🕒 Neueste Transkripte
|
||||
</a>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses?content=insights"
|
||||
class="px-4 py-2 bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all text-sm font-medium"
|
||||
>
|
||||
💡 Nur Key Insights
|
||||
</a>
|
||||
<a
|
||||
href="/speakers/rory-sutherland/analyses?content=quotes"
|
||||
class="px-4 py-2 bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all text-sm font-medium"
|
||||
>
|
||||
💬 Nur Zitate
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Related Resources -->
|
||||
<section class="py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">Weitere Ressourcen</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<!-- Books -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📚</span> Bücher
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Alchemy: The Dark Art and Curious Science of Creating Magic in Brands
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Transport for Humans (Co-Author)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Articles -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📝</span> Artikel & Essays
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Spectator Column </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> Marketing Week Articles </a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Podcasts -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>🎙️</span> Podcast Auftritte
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Tim Ferriss Show </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Knowledge Project </a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -12,276 +12,271 @@ const currentPath = Astro.url.pathname;
|
|||
|
||||
// Simon Sinek's data
|
||||
const speakerData = {
|
||||
name: "Simon Sinek",
|
||||
title: "Leadership Expert & Author",
|
||||
company: "Start With Why",
|
||||
bio: "Simon Sinek ist ein inspirierender Redner und Autor, der dafür bekannt ist, Führungskräfte dabei zu helfen, ihr 'Warum' zu finden. Sein berühmter TED Talk 'How Great Leaders Inspire Action' hat über 60 Millionen Aufrufe und ist einer der meistgesehenen TED Talks aller Zeiten. Als Autor von 'Start with Why', 'Leaders Eat Last' und 'The Infinite Game' hat er das Verständnis von Führung revolutioniert.",
|
||||
twitter: "simonsinek",
|
||||
linkedin: "simonsinek",
|
||||
website: "https://simonsinek.com"
|
||||
name: 'Simon Sinek',
|
||||
title: 'Leadership Expert & Author',
|
||||
company: 'Start With Why',
|
||||
bio: "Simon Sinek ist ein inspirierender Redner und Autor, der dafür bekannt ist, Führungskräfte dabei zu helfen, ihr 'Warum' zu finden. Sein berühmter TED Talk 'How Great Leaders Inspire Action' hat über 60 Millionen Aufrufe und ist einer der meistgesehenen TED Talks aller Zeiten. Als Autor von 'Start with Why', 'Leaders Eat Last' und 'The Infinite Game' hat er das Verständnis von Führung revolutioniert.",
|
||||
twitter: 'simonsinek',
|
||||
linkedin: 'simonsinek',
|
||||
website: 'https://simonsinek.com',
|
||||
};
|
||||
|
||||
const statsData = {
|
||||
totalTalks: 4,
|
||||
totalDuration: "3.2",
|
||||
totalViews: "200M+",
|
||||
topTopics: ["Leadership", "Purpose", "Trust", "Team Building"],
|
||||
firstTalk: "2009",
|
||||
latestTalk: "2024"
|
||||
totalTalks: 4,
|
||||
totalDuration: '3.2',
|
||||
totalViews: '200M+',
|
||||
topTopics: ['Leadership', 'Purpose', 'Trust', 'Team Building'],
|
||||
firstTalk: '2009',
|
||||
latestTalk: '2024',
|
||||
};
|
||||
|
||||
const quotes = [
|
||||
{
|
||||
text: "People don't buy what you do; they buy why you do it. And what you do simply proves what you believe.",
|
||||
talk: "How Great Leaders Inspire Action",
|
||||
context: "Das Grundprinzip des Golden Circle - Start with Why"
|
||||
},
|
||||
{
|
||||
text: "Leadership is not about being in charge. Leadership is about taking care of those in your charge.",
|
||||
talk: "Why Good Leaders Make You Feel Safe",
|
||||
context: "Über die wahre Bedeutung von Führung und Verantwortung"
|
||||
},
|
||||
{
|
||||
text: "A boss has the title, a leader has the people.",
|
||||
talk: "Leaders Eat Last",
|
||||
context: "Der Unterschied zwischen Autorität und echter Führung"
|
||||
},
|
||||
{
|
||||
text: "The cost of leadership is self-interest.",
|
||||
talk: "Leaders Eat Last",
|
||||
context: "Warum echte Führung Opferbereitschaft erfordert"
|
||||
},
|
||||
{
|
||||
text: "Trust is not formed through a screen, it's formed through personal interaction.",
|
||||
talk: "Millennials in the Workplace",
|
||||
context: "Über die Bedeutung von persönlichen Beziehungen im digitalen Zeitalter"
|
||||
},
|
||||
{
|
||||
text: "When we feel safe inside the organization, we will naturally combine our talents and our strengths and work tirelessly to face the dangers outside.",
|
||||
talk: "Why Good Leaders Make You Feel Safe",
|
||||
context: "Wie psychologische Sicherheit Teams stärker macht"
|
||||
},
|
||||
{
|
||||
text: "Working hard for something we don't care about is called stress. Working hard for something we love is called passion.",
|
||||
talk: "Love Your Work",
|
||||
context: "Der Unterschied zwischen Stress und Leidenschaft in der Arbeit"
|
||||
},
|
||||
{
|
||||
text: "Leadership is a choice. It is not a rank.",
|
||||
talk: "Why Good Leaders Make You Feel Safe",
|
||||
context: "Führung als bewusste Entscheidung, nicht als Position"
|
||||
}
|
||||
{
|
||||
text: "People don't buy what you do; they buy why you do it. And what you do simply proves what you believe.",
|
||||
talk: 'How Great Leaders Inspire Action',
|
||||
context: 'Das Grundprinzip des Golden Circle - Start with Why',
|
||||
},
|
||||
{
|
||||
text: 'Leadership is not about being in charge. Leadership is about taking care of those in your charge.',
|
||||
talk: 'Why Good Leaders Make You Feel Safe',
|
||||
context: 'Über die wahre Bedeutung von Führung und Verantwortung',
|
||||
},
|
||||
{
|
||||
text: 'A boss has the title, a leader has the people.',
|
||||
talk: 'Leaders Eat Last',
|
||||
context: 'Der Unterschied zwischen Autorität und echter Führung',
|
||||
},
|
||||
{
|
||||
text: 'The cost of leadership is self-interest.',
|
||||
talk: 'Leaders Eat Last',
|
||||
context: 'Warum echte Führung Opferbereitschaft erfordert',
|
||||
},
|
||||
{
|
||||
text: "Trust is not formed through a screen, it's formed through personal interaction.",
|
||||
talk: 'Millennials in the Workplace',
|
||||
context: 'Über die Bedeutung von persönlichen Beziehungen im digitalen Zeitalter',
|
||||
},
|
||||
{
|
||||
text: 'When we feel safe inside the organization, we will naturally combine our talents and our strengths and work tirelessly to face the dangers outside.',
|
||||
talk: 'Why Good Leaders Make You Feel Safe',
|
||||
context: 'Wie psychologische Sicherheit Teams stärker macht',
|
||||
},
|
||||
{
|
||||
text: "Working hard for something we don't care about is called stress. Working hard for something we love is called passion.",
|
||||
talk: 'Love Your Work',
|
||||
context: 'Der Unterschied zwischen Stress und Leidenschaft in der Arbeit',
|
||||
},
|
||||
{
|
||||
text: 'Leadership is a choice. It is not a rank.',
|
||||
talk: 'Why Good Leaders Make You Feel Safe',
|
||||
context: 'Führung als bewusste Entscheidung, nicht als Position',
|
||||
},
|
||||
];
|
||||
|
||||
const talks = [
|
||||
{
|
||||
id: "1",
|
||||
title: "How Great Leaders Inspire Action (Start with Why)",
|
||||
date: "September 2009",
|
||||
duration: "18:04",
|
||||
description: "Simon Sineks berühmter TED Talk über das Golden Circle Modell - warum großartige Führungskräfte mit dem 'Warum' beginnen und wie dies das Verhalten und die Loyalität von Menschen beeinflusst.",
|
||||
tags: ["Leadership", "Purpose", "Golden Circle", "Inspiration"],
|
||||
url: "/talks/simon-sinek-start-with-why",
|
||||
views: "60M+"
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
title: "Why Good Leaders Make You Feel Safe",
|
||||
date: "März 2014",
|
||||
duration: "11:59",
|
||||
description: "Ein kraftvoller Vortrag darüber, wie echte Führung bedeutet, Sicherheit für das Team zu schaffen, damit Menschen ihr Bestes geben können und bereit sind, füreinander einzustehen.",
|
||||
tags: ["Leadership", "Trust", "Safety", "Team Building"],
|
||||
url: "/talks/why-good-leaders-make-you-feel-safe",
|
||||
views: "18M+"
|
||||
},
|
||||
{
|
||||
id: "3",
|
||||
title: "Millennials in the Workplace",
|
||||
date: "Januar 2017",
|
||||
duration: "15:18",
|
||||
description: "Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation im Arbeitsplatz - von der Auswirkung der Technologie bis hin zu veränderten Arbeitserwartungen.",
|
||||
tags: ["Millennials", "Workplace", "Technology", "Generational Change"],
|
||||
url: "/talks/millennials-in-the-workplace",
|
||||
views: "100M+"
|
||||
},
|
||||
{
|
||||
id: "4",
|
||||
title: "Love Your Work",
|
||||
date: "Oktober 2012",
|
||||
duration: "42:29",
|
||||
description: "Ein inspirierender Talk über die Bedeutung von Leidenschaft bei der Arbeit und wie man eine Karriere aufbaut, die nicht nur erfolgreich, sondern auch erfüllend ist.",
|
||||
tags: ["Career", "Passion", "Purpose", "Work-Life Balance"],
|
||||
url: "/talks/simon-sinek-love-your-work",
|
||||
views: "2.8M"
|
||||
}
|
||||
{
|
||||
id: '1',
|
||||
title: 'How Great Leaders Inspire Action (Start with Why)',
|
||||
date: 'September 2009',
|
||||
duration: '18:04',
|
||||
description:
|
||||
"Simon Sineks berühmter TED Talk über das Golden Circle Modell - warum großartige Führungskräfte mit dem 'Warum' beginnen und wie dies das Verhalten und die Loyalität von Menschen beeinflusst.",
|
||||
tags: ['Leadership', 'Purpose', 'Golden Circle', 'Inspiration'],
|
||||
url: '/talks/simon-sinek-start-with-why',
|
||||
views: '60M+',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Why Good Leaders Make You Feel Safe',
|
||||
date: 'März 2014',
|
||||
duration: '11:59',
|
||||
description:
|
||||
'Ein kraftvoller Vortrag darüber, wie echte Führung bedeutet, Sicherheit für das Team zu schaffen, damit Menschen ihr Bestes geben können und bereit sind, füreinander einzustehen.',
|
||||
tags: ['Leadership', 'Trust', 'Safety', 'Team Building'],
|
||||
url: '/talks/why-good-leaders-make-you-feel-safe',
|
||||
views: '18M+',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Millennials in the Workplace',
|
||||
date: 'Januar 2017',
|
||||
duration: '15:18',
|
||||
description:
|
||||
'Simon Sineks virales Interview über die Herausforderungen der Millennial-Generation im Arbeitsplatz - von der Auswirkung der Technologie bis hin zu veränderten Arbeitserwartungen.',
|
||||
tags: ['Millennials', 'Workplace', 'Technology', 'Generational Change'],
|
||||
url: '/talks/millennials-in-the-workplace',
|
||||
views: '100M+',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Love Your Work',
|
||||
date: 'Oktober 2012',
|
||||
duration: '42:29',
|
||||
description:
|
||||
'Ein inspirierender Talk über die Bedeutung von Leidenschaft bei der Arbeit und wie man eine Karriere aufbaut, die nicht nur erfolgreich, sondern auch erfüllend ist.',
|
||||
tags: ['Career', 'Passion', 'Purpose', 'Work-Life Balance'],
|
||||
url: '/talks/simon-sinek-love-your-work',
|
||||
views: '2.8M',
|
||||
},
|
||||
];
|
||||
|
||||
// Key Concepts Section Data
|
||||
const keyConcepts = [
|
||||
{
|
||||
title: "The Golden Circle",
|
||||
description: "Start with Why, then How, then What. Großartige Führungskräfte denken, handeln und kommunizieren von innen nach außen - sie beginnen mit dem Zweck, nicht mit dem Produkt.",
|
||||
icon: "🎯"
|
||||
},
|
||||
{
|
||||
title: "Circle of Safety",
|
||||
description: "Wenn Führungskräfte einen Kreis der Sicherheit schaffen, fühlen sich Menschen geschützt und können ihr volles Potentual entfalten. Vertrauen und Kooperation entstehen natürlich.",
|
||||
icon: "🛡️"
|
||||
},
|
||||
{
|
||||
title: "Leaders Eat Last",
|
||||
description: "Echte Führungskräfte stellen die Bedürfnisse ihrer Teammitglieder vor ihre eigenen. Sie gehen die Risiken zuerst ein und sorgen dafür, dass ihr Team geschützt ist.",
|
||||
icon: "🍽️"
|
||||
},
|
||||
{
|
||||
title: "The Infinite Game",
|
||||
description: "In endlichen Spielen geht es ums Gewinnen, in unendlichen Spielen ums Weiterspielen. Erfolgreiche Organisationen denken langfristig und spielen das infinite Spiel.",
|
||||
icon: "♾️"
|
||||
}
|
||||
{
|
||||
title: 'The Golden Circle',
|
||||
description:
|
||||
'Start with Why, then How, then What. Großartige Führungskräfte denken, handeln und kommunizieren von innen nach außen - sie beginnen mit dem Zweck, nicht mit dem Produkt.',
|
||||
icon: '🎯',
|
||||
},
|
||||
{
|
||||
title: 'Circle of Safety',
|
||||
description:
|
||||
'Wenn Führungskräfte einen Kreis der Sicherheit schaffen, fühlen sich Menschen geschützt und können ihr volles Potentual entfalten. Vertrauen und Kooperation entstehen natürlich.',
|
||||
icon: '🛡️',
|
||||
},
|
||||
{
|
||||
title: 'Leaders Eat Last',
|
||||
description:
|
||||
'Echte Führungskräfte stellen die Bedürfnisse ihrer Teammitglieder vor ihre eigenen. Sie gehen die Risiken zuerst ein und sorgen dafür, dass ihr Team geschützt ist.',
|
||||
icon: '🍽️',
|
||||
},
|
||||
{
|
||||
title: 'The Infinite Game',
|
||||
description:
|
||||
'In endlichen Spielen geht es ums Gewinnen, in unendlichen Spielen ums Weiterspielen. Erfolgreiche Organisationen denken langfristig und spielen das infinite Spiel.',
|
||||
icon: '♾️',
|
||||
},
|
||||
];
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="ocean">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Simon Sinek - Speaker Profile | YouTube Wisdom Library</title>
|
||||
<meta name="description" content="Entdecken Sie alle Vorträge und Insights von Simon Sinek über Leadership, Purpose und das Start with Why Prinzip.">
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<SpeakerHero {...speakerData} />
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<SpeakerStats {...statsData} />
|
||||
|
||||
<!-- Key Concepts Section -->
|
||||
<section class="py-12 bg-theme-card/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">
|
||||
Zentrale Konzepte & Ideen
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{keyConcepts.map(concept => (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-4xl">{concept.icon}</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-2">
|
||||
{concept.title}
|
||||
</h3>
|
||||
<p class="text-theme-text-muted">
|
||||
{concept.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Talks Grid -->
|
||||
<TalkGrid talks={talks} />
|
||||
|
||||
<!-- Quotes Collection -->
|
||||
<div class="bg-theme-card/30">
|
||||
<QuoteCollection quotes={quotes} speakerName="Simon Sinek" />
|
||||
</div>
|
||||
|
||||
<!-- Related Resources -->
|
||||
<section class="py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">
|
||||
Weitere Ressourcen
|
||||
</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<!-- Books -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📚</span> Bücher
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Start with Why: How Great Leaders Inspire Everyone to Take Action
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Leaders Eat Last: Why Some Teams Pull Together and Others Don't
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Infinite Game
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Find Your Why: A Practical Guide for Discovering Purpose
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Articles -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📝</span> Artikel & Essays
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Harvard Business Review Beiträge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
LinkedIn Articles
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Medium Essays
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Podcasts -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>🎙️</span> Podcast Auftritte
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Tim Ferriss Show
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
The Diary of a CEO
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
A Bit of Optimism (His Own Podcast)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Simon Sinek - Speaker Profile | YouTube Wisdom Library</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Entdecken Sie alle Vorträge und Insights von Simon Sinek über Leadership, Purpose und das Start with Why Prinzip."
|
||||
/>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<!-- Hero Section -->
|
||||
<SpeakerHero {...speakerData} />
|
||||
|
||||
<!-- Stats Dashboard -->
|
||||
<SpeakerStats {...statsData} />
|
||||
|
||||
<!-- Key Concepts Section -->
|
||||
<section class="py-12 bg-theme-card/30">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">Zentrale Konzepte & Ideen</h2>
|
||||
|
||||
<div class="grid md:grid-cols-2 gap-6">
|
||||
{
|
||||
keyConcepts.map((concept) => (
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300">
|
||||
<div class="flex items-start gap-4">
|
||||
<span class="text-4xl">{concept.icon}</span>
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-2">{concept.title}</h3>
|
||||
<p class="text-theme-text-muted">{concept.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Talks Grid -->
|
||||
<TalkGrid talks={talks} />
|
||||
|
||||
<!-- Quotes Collection -->
|
||||
<div class="bg-theme-card/30">
|
||||
<QuoteCollection quotes={quotes} speakerName="Simon Sinek" />
|
||||
</div>
|
||||
|
||||
<!-- Related Resources -->
|
||||
<section class="py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<h2 class="text-3xl font-bold text-theme-text mb-8">Weitere Ressourcen</h2>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-6">
|
||||
<!-- Books -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📚</span> Bücher
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Start with Why: How Great Leaders Inspire Everyone to Take Action
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Leaders Eat Last: Why Some Teams Pull Together and Others Don't
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Infinite Game </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Find Your Why: A Practical Guide for Discovering Purpose
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Articles -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>📝</span> Artikel & Essays
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
Harvard Business Review Beiträge
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> LinkedIn Articles </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> Medium Essays </a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Podcasts -->
|
||||
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
|
||||
<h3 class="text-xl font-semibold text-theme-text mb-4 flex items-center gap-2">
|
||||
<span>🎙️</span> Podcast Auftritte
|
||||
</h3>
|
||||
<ul class="space-y-2">
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Tim Ferriss Show </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline"> The Diary of a CEO </a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="#" class="text-theme-primary hover:underline">
|
||||
A Bit of Optimism (His Own Podcast)
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,11 +6,11 @@ import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
|
|||
import '../../styles/themes.css';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
}
|
||||
|
||||
const { talk } = Astro.props;
|
||||
|
|
@ -18,264 +18,269 @@ const { Content } = await talk.render();
|
|||
const currentPath = Astro.url.pathname;
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="ocean">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Alle h2 Überschriften im Content-Bereich finden
|
||||
const contentDiv = document.querySelector('.content');
|
||||
if (!contentDiv) return;
|
||||
|
||||
const headings = contentDiv.querySelectorAll('h2');
|
||||
|
||||
headings.forEach(heading => {
|
||||
// Toggle-Button hinzufügen
|
||||
heading.style.cursor = 'pointer';
|
||||
heading.style.userSelect = 'none';
|
||||
heading.style.position = 'relative';
|
||||
heading.style.paddingLeft = '30px';
|
||||
|
||||
// Pfeil-Icon hinzufügen
|
||||
const arrow = document.createElement('span');
|
||||
arrow.textContent = '▼';
|
||||
arrow.style.position = 'absolute';
|
||||
arrow.style.left = '0';
|
||||
arrow.style.transition = 'transform 0.3s ease';
|
||||
arrow.style.display = 'inline-block';
|
||||
arrow.className = 'section-arrow';
|
||||
heading.insertBefore(arrow, heading.firstChild);
|
||||
|
||||
// Alle Elemente zwischen dieser und der nächsten h2 sammeln
|
||||
const content = [];
|
||||
let sibling = heading.nextElementSibling;
|
||||
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
content.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
// Container für den Inhalt erstellen
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'collapsible-content';
|
||||
contentWrapper.style.overflow = 'hidden';
|
||||
contentWrapper.style.transition = 'max-height 0.3s ease';
|
||||
contentWrapper.style.maxHeight = 'none';
|
||||
|
||||
// Inhalt in den Wrapper verschieben
|
||||
content.forEach(elem => {
|
||||
contentWrapper.appendChild(elem);
|
||||
});
|
||||
|
||||
// Wrapper nach der Überschrift einfügen
|
||||
heading.insertAdjacentElement('afterend', contentWrapper);
|
||||
|
||||
// Initial-Zustand: Transkript eingeklappt, andere ausgeklappt
|
||||
const isTranscript = heading.textContent.includes('Full Transcript') ||
|
||||
heading.textContent.includes('Transkript');
|
||||
|
||||
if (isTranscript) {
|
||||
contentWrapper.style.maxHeight = '0';
|
||||
arrow.style.transform = 'rotate(-90deg)';
|
||||
contentWrapper.dataset.collapsed = 'true';
|
||||
} else {
|
||||
// Höhe berechnen für ausgeklappten Zustand
|
||||
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
|
||||
contentWrapper.dataset.collapsed = 'false';
|
||||
}
|
||||
|
||||
// Click-Handler
|
||||
heading.addEventListener('click', () => {
|
||||
const isCollapsed = contentWrapper.dataset.collapsed === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
contentWrapper.dataset.collapsed = 'false';
|
||||
} else {
|
||||
contentWrapper.style.maxHeight = '0';
|
||||
arrow.style.transform = 'rotate(-90deg)';
|
||||
contentWrapper.dataset.collapsed = 'true';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.article-header {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 3rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.article-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.speaker:hover {
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.content {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 3rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
color: rgb(var(--theme-primary));
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.8rem;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.content h2:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Alle h2 Überschriften im Content-Bereich finden
|
||||
const contentDiv = document.querySelector('.content');
|
||||
if (!contentDiv) return;
|
||||
|
||||
.content h3 {
|
||||
color: rgb(var(--theme-primary));
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
const headings = contentDiv.querySelectorAll('h2');
|
||||
|
||||
.content ul, .content ol {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
headings.forEach((heading) => {
|
||||
// Toggle-Button hinzufügen
|
||||
heading.style.cursor = 'pointer';
|
||||
heading.style.userSelect = 'none';
|
||||
heading.style.position = 'relative';
|
||||
heading.style.paddingLeft = '30px';
|
||||
|
||||
.content li {
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
// Pfeil-Icon hinzufügen
|
||||
const arrow = document.createElement('span');
|
||||
arrow.textContent = '▼';
|
||||
arrow.style.position = 'absolute';
|
||||
arrow.style.left = '0';
|
||||
arrow.style.transition = 'transform 0.3s ease';
|
||||
arrow.style.display = 'inline-block';
|
||||
arrow.className = 'section-arrow';
|
||||
heading.insertBefore(arrow, heading.firstChild);
|
||||
|
||||
.content strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content blockquote {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1rem 2rem;
|
||||
margin: 2rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
// Alle Elemente zwischen dieser und der nächsten h2 sammeln
|
||||
const content = [];
|
||||
let sibling = heading.nextElementSibling;
|
||||
|
||||
.content blockquote p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 2rem;
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
content.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
.content hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
// Container für den Inhalt erstellen
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'collapsible-content';
|
||||
contentWrapper.style.overflow = 'hidden';
|
||||
contentWrapper.style.transition = 'max-height 0.3s ease';
|
||||
contentWrapper.style.maxHeight = 'none';
|
||||
|
||||
.content em {
|
||||
font-style: italic;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<div class="article-header">
|
||||
<div class="article-container">
|
||||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta">
|
||||
{talk.data.speakerId ? (
|
||||
<a href={`/speakers/${talk.data.speakerId}`} class="speaker hover:text-theme-primary transition-colors">
|
||||
🎤 {talk.data.speaker}
|
||||
</a>
|
||||
) : (
|
||||
<span class="speaker">🎤 {talk.data.speaker}</span>
|
||||
)}
|
||||
<span>📅 {talk.data.venue}</span>
|
||||
<span>⏱️ {talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
{talk.data.tags.map((tag: string) => (
|
||||
<span class="tag">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-container">
|
||||
<div class="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
// Inhalt in den Wrapper verschieben
|
||||
content.forEach((elem) => {
|
||||
contentWrapper.appendChild(elem);
|
||||
});
|
||||
|
||||
// Wrapper nach der Überschrift einfügen
|
||||
heading.insertAdjacentElement('afterend', contentWrapper);
|
||||
|
||||
// Initial-Zustand: Transkript eingeklappt, andere ausgeklappt
|
||||
const isTranscript =
|
||||
heading.textContent.includes('Full Transcript') ||
|
||||
heading.textContent.includes('Transkript');
|
||||
|
||||
if (isTranscript) {
|
||||
contentWrapper.style.maxHeight = '0';
|
||||
arrow.style.transform = 'rotate(-90deg)';
|
||||
contentWrapper.dataset.collapsed = 'true';
|
||||
} else {
|
||||
// Höhe berechnen für ausgeklappten Zustand
|
||||
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
|
||||
contentWrapper.dataset.collapsed = 'false';
|
||||
}
|
||||
|
||||
// Click-Handler
|
||||
heading.addEventListener('click', () => {
|
||||
const isCollapsed = contentWrapper.dataset.collapsed === 'true';
|
||||
|
||||
if (isCollapsed) {
|
||||
contentWrapper.style.maxHeight = contentWrapper.scrollHeight + 'px';
|
||||
arrow.style.transform = 'rotate(0deg)';
|
||||
contentWrapper.dataset.collapsed = 'false';
|
||||
} else {
|
||||
contentWrapper.style.maxHeight = '0';
|
||||
arrow.style.transform = 'rotate(-90deg)';
|
||||
contentWrapper.dataset.collapsed = 'true';
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<style>
|
||||
.article-header {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 3rem 0;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.article-container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.speaker {
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a.speaker:hover {
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: rgba(var(--theme-primary), 0.1);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
color: rgb(var(--theme-primary));
|
||||
}
|
||||
|
||||
.content {
|
||||
background: rgb(var(--theme-card));
|
||||
padding: 3rem;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.content h2 {
|
||||
color: rgb(var(--theme-primary));
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.8rem;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.content h2:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.collapsible-content {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content h3 {
|
||||
color: rgb(var(--theme-primary));
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.8rem;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.content p {
|
||||
margin-bottom: 1.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.content ul,
|
||||
.content ol {
|
||||
margin-bottom: 1.5rem;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.content li {
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.content strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content blockquote {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1rem 2rem;
|
||||
margin: 2rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.content blockquote p {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-block;
|
||||
margin-bottom: 2rem;
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.content hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.content em {
|
||||
font-style: italic;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text min-h-screen">
|
||||
<ThemeSwitcher />
|
||||
<Navigation currentPath={currentPath} />
|
||||
|
||||
<div class="article-header">
|
||||
<div class="article-container">
|
||||
<a href="/" class="back-link">← Zurück zur Übersicht</a>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta">
|
||||
{
|
||||
talk.data.speakerId ? (
|
||||
<a
|
||||
href={`/speakers/${talk.data.speakerId}`}
|
||||
class="speaker hover:text-theme-primary transition-colors"
|
||||
>
|
||||
🎤 {talk.data.speaker}
|
||||
</a>
|
||||
) : (
|
||||
<span class="speaker">🎤 {talk.data.speaker}</span>
|
||||
)
|
||||
}
|
||||
<span>📅 {talk.data.venue}</span>
|
||||
<span>⏱️ {talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="tags">
|
||||
{talk.data.tags.map((tag: string) => <span class="tag">{tag}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="article-container">
|
||||
<div class="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -5,344 +5,358 @@ import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
|
|||
import '../../styles/themes.css';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
}
|
||||
|
||||
const { talk } = Astro.props;
|
||||
const { Content } = await talk.render();
|
||||
---
|
||||
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="de" data-theme="ocean">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>{talk.data.title} - {talk.data.speaker} | YouTube Wisdom Library</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: rgb(var(--theme-background));
|
||||
}
|
||||
.app-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
background: rgb(var(--theme-background));
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 320px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
.main-content {
|
||||
flex: 1;
|
||||
margin-left: 320px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 4rem;
|
||||
}
|
||||
.content-wrapper {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
padding: 3rem 4rem;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.content-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-text));
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-text));
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.95rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
.meta-info {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 0.95rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--theme-primary), 0.08) 0%,
|
||||
rgba(var(--theme-secondary), 0.05) 100%);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgb(var(--theme-text));
|
||||
}
|
||||
.highlight-box {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--theme-primary), 0.08) 0%,
|
||||
rgba(var(--theme-secondary), 0.05) 100%
|
||||
);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: rgb(var(--theme-text));
|
||||
}
|
||||
|
||||
.content-body {
|
||||
color: rgb(var(--theme-text));
|
||||
line-height: 1.8;
|
||||
}
|
||||
.content-body {
|
||||
color: rgb(var(--theme-text));
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
/* Content styling */
|
||||
.content-body h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 3rem 0 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
/* Content styling */
|
||||
.content-body h2 {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 3rem 0 1.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.content-body h2:hover {
|
||||
color: rgb(var(--theme-secondary));
|
||||
}
|
||||
.content-body h2:hover {
|
||||
color: rgb(var(--theme-secondary));
|
||||
}
|
||||
|
||||
.content-body h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
.content-body h3 {
|
||||
font-size: 1.3rem;
|
||||
font-weight: 600;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.content-body p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.content-body p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content-body ul, .content-body ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.content-body ul,
|
||||
.content-body ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.content-body li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.content-body li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-body blockquote {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
.content-body blockquote {
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
padding: 1.25rem 1.5rem;
|
||||
margin: 2rem 0;
|
||||
border-radius: 0.5rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.content-body blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
.content-body blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-body strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
.content-body strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-body em {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
.content-body em {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
|
||||
.content-body hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
.content-body hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.section-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.section-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-collapsed {
|
||||
display: none;
|
||||
}
|
||||
.section-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.collapse-arrow {
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Mobile toggle button */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 101;
|
||||
background: rgb(var(--theme-card));
|
||||
border: 1px solid rgba(var(--theme-primary), 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Mobile toggle button */
|
||||
.mobile-menu-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
z-index: 101;
|
||||
background: rgb(var(--theme-card));
|
||||
border: 1px solid rgba(var(--theme-primary), 0.2);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
.content-wrapper {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.mobile-menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
.mobile-menu-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.content-body h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<div class="app-container">
|
||||
<TalksSidebar />
|
||||
|
||||
<button class="mobile-menu-toggle" id="menuToggle">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 12H21M3 6H21M3 18H21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
.content-body h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-theme-background text-theme-text">
|
||||
<ThemeSwitcher />
|
||||
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
<div class="content-header">
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> / <a href="/speakers">Speakers</a> / {talk.data.speaker}
|
||||
</div>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="meta-item">
|
||||
<span>🎤</span>
|
||||
<span>{talk.data.speaker}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📅</span>
|
||||
<span>{talk.data.venue}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>⏱️</span>
|
||||
<span>{talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📖</span>
|
||||
<span>{talk.data.readingTime} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-container">
|
||||
<TalksSidebar />
|
||||
|
||||
<div class="highlight-box">
|
||||
💡 {talk.data.summary}
|
||||
</div>
|
||||
<button class="mobile-menu-toggle" id="menuToggle">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M3 12H21M3 6H21M3 18H21"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="content-body" id="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<main class="main-content">
|
||||
<div class="content-wrapper">
|
||||
<div class="content-header">
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> / <a href="/speakers">Speakers</a> / {talk.data.speaker}
|
||||
</div>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="meta-item">
|
||||
<span>🎤</span>
|
||||
<span>{talk.data.speaker}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📅</span>
|
||||
<span>{talk.data.venue}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>⏱️</span>
|
||||
<span>{talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📖</span>
|
||||
<span>{talk.data.readingTime} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.getElementById('menuToggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
||||
if (menuToggle && sidebar) {
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
<div class="highlight-box">
|
||||
💡 {talk.data.summary}
|
||||
</div>
|
||||
|
||||
// Collapsible sections
|
||||
const content = document.getElementById('content');
|
||||
if (!content) return;
|
||||
<div class="content-body" id="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
const headings = content.querySelectorAll('h2');
|
||||
|
||||
headings.forEach(heading => {
|
||||
// Add collapse arrow
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'collapse-arrow';
|
||||
arrow.textContent = '▼';
|
||||
heading.appendChild(arrow);
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Mobile menu toggle
|
||||
const menuToggle = document.getElementById('menuToggle');
|
||||
const sidebar = document.querySelector('.sidebar');
|
||||
|
||||
// Collect content until next h2
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'section-wrapper';
|
||||
|
||||
let sibling = heading.nextElementSibling;
|
||||
const elements = [];
|
||||
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
elements.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
elements.forEach(el => wrapper.appendChild(el));
|
||||
heading.insertAdjacentElement('afterend', wrapper);
|
||||
if (menuToggle && sidebar) {
|
||||
menuToggle.addEventListener('click', () => {
|
||||
sidebar.classList.toggle('open');
|
||||
});
|
||||
}
|
||||
|
||||
// Collapse transcript by default
|
||||
if (heading.textContent.toLowerCase().includes('transcript')) {
|
||||
wrapper.classList.add('section-collapsed');
|
||||
arrow.classList.add('collapsed');
|
||||
}
|
||||
// Collapsible sections
|
||||
const content = document.getElementById('content');
|
||||
if (!content) return;
|
||||
|
||||
// Toggle on click
|
||||
heading.addEventListener('click', () => {
|
||||
wrapper.classList.toggle('section-collapsed');
|
||||
arrow.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
const headings = content.querySelectorAll('h2');
|
||||
|
||||
headings.forEach((heading) => {
|
||||
// Add collapse arrow
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'collapse-arrow';
|
||||
arrow.textContent = '▼';
|
||||
heading.appendChild(arrow);
|
||||
|
||||
// Collect content until next h2
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'section-wrapper';
|
||||
|
||||
let sibling = heading.nextElementSibling;
|
||||
const elements = [];
|
||||
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
elements.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
elements.forEach((el) => wrapper.appendChild(el));
|
||||
heading.insertAdjacentElement('afterend', wrapper);
|
||||
|
||||
// Collapse transcript by default
|
||||
if (heading.textContent.toLowerCase().includes('transcript')) {
|
||||
wrapper.classList.add('section-collapsed');
|
||||
arrow.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Toggle on click
|
||||
heading.addEventListener('click', () => {
|
||||
wrapper.classList.toggle('section-collapsed');
|
||||
arrow.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@ import { getCollection } from 'astro:content';
|
|||
import BaseLayout from '../../layouts/BaseLayout.astro';
|
||||
|
||||
export async function getStaticPaths() {
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
const talks = await getCollection('talks');
|
||||
return talks.map((talk) => ({
|
||||
params: { slug: talk.slug },
|
||||
props: { talk },
|
||||
}));
|
||||
}
|
||||
|
||||
const { talk } = Astro.props;
|
||||
|
|
@ -16,392 +16,401 @@ const title = `${talk.data.title} - ${talk.data.speaker}`;
|
|||
---
|
||||
|
||||
<BaseLayout title={title} description={talk.data.summary}>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-header">
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> / <a href="/speakers">Speakers</a> / {talk.data.speaker}
|
||||
</div>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="meta-item">
|
||||
<span>🎤</span>
|
||||
<span>{talk.data.speaker}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📅</span>
|
||||
<span>{talk.data.venue}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>⏱️</span>
|
||||
<span>{talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📖</span>
|
||||
<span>{talk.data.readingTime} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-wrapper">
|
||||
<div class="content-header">
|
||||
<div class="breadcrumb">
|
||||
<a href="/">Home</a> / <a href="/speakers">Speakers</a> / {talk.data.speaker}
|
||||
</div>
|
||||
<h1>{talk.data.title}</h1>
|
||||
<div class="meta-info">
|
||||
<div class="meta-item">
|
||||
<span>🎤</span>
|
||||
<span>{talk.data.speaker}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📅</span>
|
||||
<span>{talk.data.venue}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>⏱️</span>
|
||||
<span>{talk.data.duration}</span>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span>📖</span>
|
||||
<span>{talk.data.readingTime} min read</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="highlight-box">
|
||||
<p>{talk.data.summary}</p>
|
||||
</div>
|
||||
<div class="highlight-box">
|
||||
<p>{talk.data.summary}</p>
|
||||
</div>
|
||||
|
||||
<div class="content-body" id="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
<div class="content-body" id="content">
|
||||
<Content />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.content-wrapper {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
<style>
|
||||
.content-wrapper {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.content-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.content-header {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.9rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.breadcrumb {
|
||||
font-size: 0.9rem;
|
||||
color: rgb(var(--theme-text-muted));
|
||||
margin-bottom: 2rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.breadcrumb a {
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
.breadcrumb a {
|
||||
color: rgb(var(--theme-primary));
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.breadcrumb a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 800;
|
||||
color: rgb(var(--theme-text));
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.75rem;
|
||||
font-weight: 800;
|
||||
color: rgb(var(--theme-text));
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.meta-info {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--theme-text));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
background: rgba(var(--theme-primary), 0.05);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
border-radius: 0.75rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: rgb(var(--theme-text));
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.meta-item:hover {
|
||||
background: rgba(var(--theme-primary), 0.08);
|
||||
border-color: rgba(var(--theme-primary), 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.meta-item:hover {
|
||||
background: rgba(var(--theme-primary), 0.08);
|
||||
border-color: rgba(var(--theme-primary), 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.meta-item span:first-child {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
.meta-item span:first-child {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--theme-primary), 0.1) 0%,
|
||||
rgba(var(--theme-secondary), 0.06) 100%);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.2);
|
||||
border-left: 5px solid rgb(var(--theme-primary));
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
margin: 3rem 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
color: rgb(var(--theme-text));
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.highlight-box {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--theme-primary), 0.1) 0%,
|
||||
rgba(var(--theme-secondary), 0.06) 100%
|
||||
);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.2);
|
||||
border-left: 5px solid rgb(var(--theme-primary));
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
margin: 3rem 0;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
color: rgb(var(--theme-text));
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.highlight-box::before {
|
||||
content: '💡';
|
||||
font-size: 1.5rem;
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.highlight-box::before {
|
||||
content: '💡';
|
||||
font-size: 1.5rem;
|
||||
position: absolute;
|
||||
top: 1.5rem;
|
||||
right: 2rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.highlight-box p {
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.highlight-box p {
|
||||
margin: 0;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
color: rgb(var(--theme-text));
|
||||
line-height: 1.8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.content-body {
|
||||
color: rgb(var(--theme-text));
|
||||
line-height: 1.8;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.content-body h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 4rem 0 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--theme-primary), 0.08) 0%,
|
||||
rgba(var(--theme-secondary), 0.04) 100%);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.15);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.content-body h2 {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 4rem 0 2rem;
|
||||
padding: 1.5rem;
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--theme-primary), 0.08) 0%,
|
||||
rgba(var(--theme-secondary), 0.04) 100%
|
||||
);
|
||||
border: 1px solid rgba(var(--theme-primary), 0.15);
|
||||
border-left: 4px solid rgb(var(--theme-primary));
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
transition: all 0.3s ease;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.content-body h2:hover {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--theme-primary), 0.12) 0%,
|
||||
rgba(var(--theme-secondary), 0.08) 100%);
|
||||
border-color: rgba(var(--theme-primary), 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
.content-body h2:hover {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--theme-primary), 0.12) 0%,
|
||||
rgba(var(--theme-secondary), 0.08) 100%
|
||||
);
|
||||
border-color: rgba(var(--theme-primary), 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(var(--theme-primary), 0.1);
|
||||
}
|
||||
|
||||
.content-body h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 3rem 0 1.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
.content-body h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 650;
|
||||
color: rgb(var(--theme-primary));
|
||||
margin: 3rem 0 1.5rem;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.content-body p {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
.content-body p {
|
||||
margin-bottom: 2rem;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.content-body ul, .content-body ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
.content-body ul,
|
||||
.content-body ol {
|
||||
margin: 1.5rem 0;
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
.content-body li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.content-body li {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.content-body blockquote {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(var(--theme-primary), 0.06) 0%,
|
||||
rgba(var(--theme-secondary), 0.04) 100%);
|
||||
border-left: 5px solid rgb(var(--theme-primary));
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
padding: 2rem 2.5rem;
|
||||
margin: 3rem 0;
|
||||
border-radius: 1rem;
|
||||
font-style: italic;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
position: relative;
|
||||
}
|
||||
.content-body blockquote {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(var(--theme-primary), 0.06) 0%,
|
||||
rgba(var(--theme-secondary), 0.04) 100%
|
||||
);
|
||||
border-left: 5px solid rgb(var(--theme-primary));
|
||||
border: 1px solid rgba(var(--theme-primary), 0.1);
|
||||
padding: 2rem 2.5rem;
|
||||
margin: 3rem 0;
|
||||
border-radius: 1rem;
|
||||
font-style: italic;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1.8;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.content-body blockquote::before {
|
||||
content: '"';
|
||||
font-size: 4rem;
|
||||
color: rgba(var(--theme-primary), 0.3);
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.content-body blockquote::before {
|
||||
content: '"';
|
||||
font-size: 4rem;
|
||||
color: rgba(var(--theme-primary), 0.3);
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
left: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content-body blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
.content-body blockquote p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content-body strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
.content-body strong {
|
||||
color: rgb(var(--theme-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.content-body em {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
.content-body em {
|
||||
color: rgb(var(--theme-text-muted));
|
||||
}
|
||||
|
||||
.content-body hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
.content-body hr {
|
||||
border: none;
|
||||
border-top: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
margin: 3rem 0;
|
||||
}
|
||||
|
||||
.section-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
.section-wrapper {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-collapsed {
|
||||
display: none;
|
||||
}
|
||||
.section-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.collapse-arrow {
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1.4rem;
|
||||
color: rgba(var(--theme-primary), 0.7);
|
||||
margin-left: auto;
|
||||
}
|
||||
.collapse-arrow {
|
||||
transition: transform 0.3s ease;
|
||||
font-size: 1.4rem;
|
||||
color: rgba(var(--theme-primary), 0.7);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
.collapse-arrow.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.section-wrapper {
|
||||
margin-bottom: 3rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.section-wrapper {
|
||||
margin-bottom: 3rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 2px solid rgba(var(--theme-primary), 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.section-wrapper:not(.section-collapsed) {
|
||||
border-left-color: rgba(var(--theme-primary), 0.2);
|
||||
}
|
||||
.section-wrapper:not(.section-collapsed) {
|
||||
border-left-color: rgba(var(--theme-primary), 0.2);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.content-wrapper {
|
||||
max-width: 100%;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.meta-info {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.content-body h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 3rem 0 1.5rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
.content-body h2 {
|
||||
font-size: 1.75rem;
|
||||
margin: 3rem 0 1.5rem;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.content-wrapper {
|
||||
padding: 1rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.content-body {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.content-body {
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.content-body h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 2.5rem 0 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
.content-body h2 {
|
||||
font-size: 1.5rem;
|
||||
margin: 2.5rem 0 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-body h3 {
|
||||
font-size: 1.3rem;
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
.content-body h3 {
|
||||
font-size: 1.3rem;
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.content-body p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
.content-body p {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.highlight-box {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
.highlight-box {
|
||||
padding: 1.5rem;
|
||||
font-size: 1.05rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.meta-item {
|
||||
padding: 0.875rem 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.6rem 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
.breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.6rem 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Collapsible sections
|
||||
const content = document.getElementById('content');
|
||||
if (!content) return;
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Collapsible sections
|
||||
const content = document.getElementById('content');
|
||||
if (!content) return;
|
||||
|
||||
const headings = content.querySelectorAll('h2');
|
||||
|
||||
headings.forEach(heading => {
|
||||
// Add collapse arrow
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'collapse-arrow';
|
||||
arrow.textContent = '▼';
|
||||
heading.appendChild(arrow);
|
||||
const headings = content.querySelectorAll('h2');
|
||||
|
||||
// Collect content until next h2
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'section-wrapper';
|
||||
|
||||
let sibling = heading.nextElementSibling;
|
||||
const elements = [];
|
||||
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
elements.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
elements.forEach(el => wrapper.appendChild(el));
|
||||
heading.insertAdjacentElement('afterend', wrapper);
|
||||
headings.forEach((heading) => {
|
||||
// Add collapse arrow
|
||||
const arrow = document.createElement('span');
|
||||
arrow.className = 'collapse-arrow';
|
||||
arrow.textContent = '▼';
|
||||
heading.appendChild(arrow);
|
||||
|
||||
// Collapse transcript by default
|
||||
if (heading.textContent.toLowerCase().includes('transcript')) {
|
||||
wrapper.classList.add('section-collapsed');
|
||||
arrow.classList.add('collapsed');
|
||||
}
|
||||
// Collect content until next h2
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'section-wrapper';
|
||||
|
||||
// Toggle on click
|
||||
heading.addEventListener('click', () => {
|
||||
wrapper.classList.toggle('section-collapsed');
|
||||
arrow.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
let sibling = heading.nextElementSibling;
|
||||
const elements = [];
|
||||
|
||||
while (sibling && sibling.tagName !== 'H2') {
|
||||
elements.push(sibling);
|
||||
sibling = sibling.nextElementSibling;
|
||||
}
|
||||
|
||||
elements.forEach((el) => wrapper.appendChild(el));
|
||||
heading.insertAdjacentElement('afterend', wrapper);
|
||||
|
||||
// Collapse transcript by default
|
||||
if (heading.textContent.toLowerCase().includes('transcript')) {
|
||||
wrapper.classList.add('section-collapsed');
|
||||
arrow.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Toggle on click
|
||||
heading.addEventListener('click', () => {
|
||||
wrapper.classList.toggle('section-collapsed');
|
||||
arrow.classList.toggle('collapsed');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</BaseLayout>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,37 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Transcriber",
|
||||
"slug": "transcriber",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "transcriber",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#9333ea"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.manacore.transcriber"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#9333ea"
|
||||
},
|
||||
"package": "com.manacore.transcriber"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router", "expo-secure-store"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
"expo": {
|
||||
"name": "Transcriber",
|
||||
"slug": "transcriber",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/icon.png",
|
||||
"scheme": "transcriber",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"splash": {
|
||||
"image": "./assets/splash.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#9333ea"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.manacore.transcriber"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#9333ea"
|
||||
},
|
||||
"package": "com.manacore.transcriber"
|
||||
},
|
||||
"web": {
|
||||
"bundler": "metro",
|
||||
"output": "static",
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": ["expo-router", "expo-secure-store"],
|
||||
"experiments": {
|
||||
"typedRoutes": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,53 +2,47 @@ import { Tabs } from 'expo-router';
|
|||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export default function TabLayout() {
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#9333ea',
|
||||
tabBarInactiveTintColor: '#6b7280',
|
||||
headerStyle: {
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
headerTintColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="transcribe"
|
||||
options={{
|
||||
title: 'Transcribe',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="mic" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="transcripts"
|
||||
options={{
|
||||
title: 'Transcripts',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="document-text" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="settings" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: '#9333ea',
|
||||
tabBarInactiveTintColor: '#6b7280',
|
||||
headerStyle: {
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
headerTintColor: '#fff',
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: 'Home',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="home" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="transcribe"
|
||||
options={{
|
||||
title: 'Transcribe',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="mic" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="transcripts"
|
||||
options={{
|
||||
title: 'Transcripts',
|
||||
tabBarIcon: ({ color, size }) => (
|
||||
<Ionicons name="document-text" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: 'Settings',
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings" size={size} color={color} />,
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,166 +3,162 @@ import { Link } from 'expo-router';
|
|||
import { useJobStore } from '@/stores/jobs';
|
||||
|
||||
export default function HomeScreen() {
|
||||
const { jobs, activeJobs } = useJobStore();
|
||||
const { jobs, activeJobs } = useJobStore();
|
||||
|
||||
const stats = {
|
||||
totalTranscripts: jobs.filter((j) => j.status === 'completed').length,
|
||||
activeJobs: activeJobs.length,
|
||||
};
|
||||
const stats = {
|
||||
totalTranscripts: jobs.filter((j) => j.status === 'completed').length,
|
||||
activeJobs: activeJobs.length,
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Transcriber</Text>
|
||||
<Text style={styles.subtitle}>AI-powered video transcription</Text>
|
||||
</View>
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.title}>Transcriber</Text>
|
||||
<Text style={styles.subtitle}>AI-powered video transcription</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{stats.totalTranscripts}</Text>
|
||||
<Text style={styles.statLabel}>Transcripts</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={[styles.statNumber, { color: '#eab308' }]}>
|
||||
{stats.activeJobs}
|
||||
</Text>
|
||||
<Text style={styles.statLabel}>Active Jobs</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.statsContainer}>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={styles.statNumber}>{stats.totalTranscripts}</Text>
|
||||
<Text style={styles.statLabel}>Transcripts</Text>
|
||||
</View>
|
||||
<View style={styles.statCard}>
|
||||
<Text style={[styles.statNumber, { color: '#eab308' }]}>{stats.activeJobs}</Text>
|
||||
<Text style={styles.statLabel}>Active Jobs</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Link href="/(tabs)/transcribe" asChild>
|
||||
<Pressable style={styles.button}>
|
||||
<Text style={styles.buttonText}>Start New Transcription</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Link href="/(tabs)/transcribe" asChild>
|
||||
<Pressable style={styles.button}>
|
||||
<Text style={styles.buttonText}>Start New Transcription</Text>
|
||||
</Pressable>
|
||||
</Link>
|
||||
|
||||
{activeJobs.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Active Jobs</Text>
|
||||
{activeJobs.map((job) => (
|
||||
<View key={job.id} style={styles.jobCard}>
|
||||
<Text style={styles.jobTitle} numberOfLines={1}>
|
||||
{job.videoInfo?.title || job.url}
|
||||
</Text>
|
||||
<Text style={styles.jobStatus}>{job.status}</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View
|
||||
style={[styles.progressFill, { width: `${job.progress}%` }]}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.progressText}>{job.progress}%</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
{activeJobs.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Active Jobs</Text>
|
||||
{activeJobs.map((job) => (
|
||||
<View key={job.id} style={styles.jobCard}>
|
||||
<Text style={styles.jobTitle} numberOfLines={1}>
|
||||
{job.videoInfo?.title || job.url}
|
||||
</Text>
|
||||
<Text style={styles.jobStatus}>{job.status}</Text>
|
||||
<View style={styles.progressBar}>
|
||||
<View style={[styles.progressFill, { width: `${job.progress}%` }]} />
|
||||
</View>
|
||||
<Text style={styles.progressText}>{job.progress}%</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#e9d5ff',
|
||||
marginTop: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#9333ea',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
button: {
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: '#9333ea',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
color: '#1f2937',
|
||||
},
|
||||
jobCard: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
jobTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1f2937',
|
||||
},
|
||||
jobStatus: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: 4,
|
||||
marginTop: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#9333ea',
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
header: {
|
||||
padding: 24,
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
title: {
|
||||
fontSize: 28,
|
||||
fontWeight: 'bold',
|
||||
color: '#fff',
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: 16,
|
||||
color: '#e9d5ff',
|
||||
marginTop: 4,
|
||||
},
|
||||
statsContainer: {
|
||||
flexDirection: 'row',
|
||||
padding: 16,
|
||||
gap: 16,
|
||||
},
|
||||
statCard: {
|
||||
flex: 1,
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
statNumber: {
|
||||
fontSize: 32,
|
||||
fontWeight: 'bold',
|
||||
color: '#9333ea',
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
button: {
|
||||
marginHorizontal: 16,
|
||||
backgroundColor: '#9333ea',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
color: '#1f2937',
|
||||
},
|
||||
jobCard: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
jobTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '500',
|
||||
color: '#1f2937',
|
||||
},
|
||||
jobStatus: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
textTransform: 'capitalize',
|
||||
},
|
||||
progressBar: {
|
||||
height: 8,
|
||||
backgroundColor: '#e5e7eb',
|
||||
borderRadius: 4,
|
||||
marginTop: 12,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressFill: {
|
||||
height: '100%',
|
||||
backgroundColor: '#9333ea',
|
||||
borderRadius: 4,
|
||||
},
|
||||
progressText: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,70 +1,70 @@
|
|||
import { View, Text, StyleSheet, ScrollView } from 'react-native';
|
||||
|
||||
export default function SettingsScreen() {
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Version</Text>
|
||||
<Text style={styles.value}>1.0.0</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Backend URL</Text>
|
||||
<Text style={styles.value}>http://localhost:3006</Text>
|
||||
</View>
|
||||
</View>
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>About</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Version</Text>
|
||||
<Text style={styles.value}>1.0.0</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Backend URL</Text>
|
||||
<Text style={styles.value}>http://localhost:3006</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Default Settings</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Language</Text>
|
||||
<Text style={styles.value}>German (de)</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Provider</Text>
|
||||
<Text style={styles.value}>OpenAI Whisper API</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>Default Settings</Text>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Language</Text>
|
||||
<Text style={styles.value}>German (de)</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.label}>Provider</Text>
|
||||
<Text style={styles.value}>OpenAI Whisper API</Text>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 12,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
color: '#1f2937',
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
section: {
|
||||
padding: 16,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 12,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 8,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
color: '#1f2937',
|
||||
},
|
||||
value: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,222 +1,190 @@
|
|||
import { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { View, Text, StyleSheet, TextInput, Pressable, ScrollView, Alert } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { api } from '@/services/api';
|
||||
import { useJobStore } from '@/stores/jobs';
|
||||
|
||||
export default function TranscribeScreen() {
|
||||
const [url, setUrl] = useState('');
|
||||
const [language, setLanguage] = useState('de');
|
||||
const [provider, setProvider] = useState<'openai' | 'local'>('openai');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [language, setLanguage] = useState('de');
|
||||
const [provider, setProvider] = useState<'openai' | 'local'>('openai');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const addJob = useJobStore((state) => state.addJob);
|
||||
const addJob = useJobStore((state) => state.addJob);
|
||||
|
||||
const languages = [
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
];
|
||||
const languages = [
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
];
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!url.trim()) {
|
||||
Alert.alert('Error', 'Please enter a YouTube URL');
|
||||
return;
|
||||
}
|
||||
const handleSubmit = async () => {
|
||||
if (!url.trim()) {
|
||||
Alert.alert('Error', 'Please enter a YouTube URL');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const job = await api.createJob({ url, language, provider });
|
||||
addJob(job);
|
||||
setUrl('');
|
||||
Alert.alert('Success', 'Transcription job started!', [
|
||||
{ text: 'OK', onPress: () => router.push('/(tabs)/') },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Error',
|
||||
error instanceof Error ? error.message : 'Failed to start transcription'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
setLoading(true);
|
||||
try {
|
||||
const job = await api.createJob({ url, language, provider });
|
||||
addJob(job);
|
||||
setUrl('');
|
||||
Alert.alert('Success', 'Transcription job started!', [
|
||||
{ text: 'OK', onPress: () => router.push('/(tabs)/') },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert(
|
||||
'Error',
|
||||
error instanceof Error ? error.message : 'Failed to start transcription'
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>YouTube URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
<View style={styles.form}>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>YouTube URL</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={url}
|
||||
onChangeText={setUrl}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Language</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
{languages.map((lang) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
style={[
|
||||
styles.option,
|
||||
language === lang.code && styles.optionSelected,
|
||||
]}
|
||||
onPress={() => setLanguage(lang.code)}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
language === lang.code && styles.optionTextSelected,
|
||||
]}
|
||||
>
|
||||
{lang.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Language</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
{languages.map((lang) => (
|
||||
<Pressable
|
||||
key={lang.code}
|
||||
style={[styles.option, language === lang.code && styles.optionSelected]}
|
||||
onPress={() => setLanguage(lang.code)}
|
||||
>
|
||||
<Text
|
||||
style={[styles.optionText, language === lang.code && styles.optionTextSelected]}
|
||||
>
|
||||
{lang.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Provider</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.option,
|
||||
provider === 'openai' && styles.optionSelected,
|
||||
]}
|
||||
onPress={() => setProvider('openai')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
provider === 'openai' && styles.optionTextSelected,
|
||||
]}
|
||||
>
|
||||
OpenAI
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[
|
||||
styles.option,
|
||||
provider === 'local' && styles.optionSelected,
|
||||
]}
|
||||
onPress={() => setProvider('local')}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.optionText,
|
||||
provider === 'local' && styles.optionTextSelected,
|
||||
]}
|
||||
>
|
||||
Local
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{provider === 'openai'
|
||||
? 'Fast, cloud-based transcription'
|
||||
: 'Free, requires local Whisper'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<Text style={styles.label}>Provider</Text>
|
||||
<View style={styles.optionsRow}>
|
||||
<Pressable
|
||||
style={[styles.option, provider === 'openai' && styles.optionSelected]}
|
||||
onPress={() => setProvider('openai')}
|
||||
>
|
||||
<Text style={[styles.optionText, provider === 'openai' && styles.optionTextSelected]}>
|
||||
OpenAI
|
||||
</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={[styles.option, provider === 'local' && styles.optionSelected]}
|
||||
onPress={() => setProvider('local')}
|
||||
>
|
||||
<Text style={[styles.optionText, provider === 'local' && styles.optionTextSelected]}>
|
||||
Local
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<Text style={styles.hint}>
|
||||
{provider === 'openai'
|
||||
? 'Fast, cloud-based transcription'
|
||||
: 'Free, requires local Whisper'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>
|
||||
{loading ? 'Starting...' : 'Start Transcription'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
<Pressable
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
>
|
||||
<Text style={styles.buttonText}>{loading ? 'Starting...' : 'Start Transcription'}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
},
|
||||
field: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
optionsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
option: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
},
|
||||
optionSelected: {
|
||||
backgroundColor: '#9333ea',
|
||||
borderColor: '#9333ea',
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
},
|
||||
optionTextSelected: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#9333ea',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
form: {
|
||||
padding: 16,
|
||||
},
|
||||
field: {
|
||||
marginBottom: 24,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
marginBottom: 8,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
},
|
||||
optionsRow: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
},
|
||||
option: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
backgroundColor: '#fff',
|
||||
borderWidth: 1,
|
||||
borderColor: '#d1d5db',
|
||||
borderRadius: 8,
|
||||
},
|
||||
optionSelected: {
|
||||
backgroundColor: '#9333ea',
|
||||
borderColor: '#9333ea',
|
||||
},
|
||||
optionText: {
|
||||
fontSize: 14,
|
||||
color: '#374151',
|
||||
},
|
||||
optionTextSelected: {
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
hint: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginTop: 8,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: '#9333ea',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,88 +2,84 @@ import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
|
|||
import { useJobStore } from '@/stores/jobs';
|
||||
|
||||
export default function TranscriptsScreen() {
|
||||
const { jobs } = useJobStore();
|
||||
const completedJobs = jobs.filter((j) => j.status === 'completed');
|
||||
const { jobs } = useJobStore();
|
||||
const completedJobs = jobs.filter((j) => j.status === 'completed');
|
||||
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{completedJobs.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No transcripts yet</Text>
|
||||
<Text style={styles.emptyHint}>
|
||||
Start a new transcription to see results here
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{completedJobs.map((job) => (
|
||||
<Pressable key={job.id} style={styles.card}>
|
||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||
{job.videoInfo?.title || 'Untitled'}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle}>
|
||||
{job.videoInfo?.channel || 'Unknown channel'}
|
||||
</Text>
|
||||
<Text style={styles.cardDate}>
|
||||
{new Date(job.completedAt || '').toLocaleDateString()}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
return (
|
||||
<ScrollView style={styles.container}>
|
||||
{completedJobs.length === 0 ? (
|
||||
<View style={styles.empty}>
|
||||
<Text style={styles.emptyText}>No transcripts yet</Text>
|
||||
<Text style={styles.emptyHint}>Start a new transcription to see results here</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.list}>
|
||||
{completedJobs.map((job) => (
|
||||
<Pressable key={job.id} style={styles.card}>
|
||||
<Text style={styles.cardTitle} numberOfLines={2}>
|
||||
{job.videoInfo?.title || 'Untitled'}
|
||||
</Text>
|
||||
<Text style={styles.cardSubtitle}>{job.videoInfo?.channel || 'Unknown channel'}</Text>
|
||||
<Text style={styles.cardDate}>
|
||||
{new Date(job.completedAt || '').toLocaleDateString()}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: 14,
|
||||
color: '#9ca3af',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
cardDate: {
|
||||
fontSize: 12,
|
||||
color: '#9ca3af',
|
||||
marginTop: 8,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#f9fafb',
|
||||
},
|
||||
empty: {
|
||||
flex: 1,
|
||||
padding: 32,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
emptyText: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
},
|
||||
emptyHint: {
|
||||
fontSize: 14,
|
||||
color: '#9ca3af',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
list: {
|
||||
padding: 16,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#fff',
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
marginBottom: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 14,
|
||||
color: '#6b7280',
|
||||
marginTop: 4,
|
||||
},
|
||||
cardDate: {
|
||||
fontSize: 12,
|
||||
color: '#9ca3af',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,25 +2,22 @@ import { Stack } from 'expo-router';
|
|||
import { StatusBar } from 'expo-status-bar';
|
||||
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="auto" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
headerTintColor: '#fff',
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
name="(tabs)"
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<StatusBar style="auto" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: '#9333ea',
|
||||
},
|
||||
headerTintColor: '#fff',
|
||||
headerTitleStyle: {
|
||||
fontWeight: 'bold',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,38 +1,38 @@
|
|||
{
|
||||
"name": "@wisekeep/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"build": "expo export",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"expo": "~52.0.0",
|
||||
"expo-clipboard": "~7.0.0",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"nativewind": "^4.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~18.3.0",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
"name": "@wisekeep/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"build": "expo export",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"expo": "~52.0.0",
|
||||
"expo-clipboard": "~7.0.0",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"nativewind": "^4.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~18.3.0",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,66 @@
|
|||
import Constants from 'expo-constants';
|
||||
|
||||
const API_BASE =
|
||||
Constants.expoConfig?.extra?.apiUrl || 'http://localhost:3006';
|
||||
const API_BASE = Constants.expoConfig?.extra?.apiUrl || 'http://localhost:3006';
|
||||
|
||||
export interface TranscriptionJob {
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status:
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'transcribing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status: 'pending' | 'downloading' | 'transcribing' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CreateJobRequest {
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: 'openai' | 'local';
|
||||
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: 'openai' | 'local';
|
||||
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
async function request<T>(
|
||||
endpoint: string,
|
||||
options?: RequestInit
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
createJob: (data: CreateJobRequest) =>
|
||||
request<TranscriptionJob>('/transcription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
createJob: (data: CreateJobRequest) =>
|
||||
request<TranscriptionJob>('/transcription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
|
||||
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
|
||||
|
||||
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
|
||||
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
|
||||
|
||||
cancelJob: (id: string) =>
|
||||
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
|
||||
cancelJob: (id: string) =>
|
||||
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
|
||||
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,70 +2,56 @@ import { create } from 'zustand';
|
|||
import type { TranscriptionJob } from '@/services/api';
|
||||
|
||||
interface JobStore {
|
||||
jobs: TranscriptionJob[];
|
||||
activeJobs: TranscriptionJob[];
|
||||
addJob: (job: TranscriptionJob) => void;
|
||||
updateJob: (id: string, updates: Partial<TranscriptionJob>) => void;
|
||||
removeJob: (id: string) => void;
|
||||
setJobs: (jobs: TranscriptionJob[]) => void;
|
||||
jobs: TranscriptionJob[];
|
||||
activeJobs: TranscriptionJob[];
|
||||
addJob: (job: TranscriptionJob) => void;
|
||||
updateJob: (id: string, updates: Partial<TranscriptionJob>) => void;
|
||||
removeJob: (id: string) => void;
|
||||
setJobs: (jobs: TranscriptionJob[]) => void;
|
||||
}
|
||||
|
||||
export const useJobStore = create<JobStore>((set, get) => ({
|
||||
jobs: [],
|
||||
activeJobs: [],
|
||||
jobs: [],
|
||||
activeJobs: [],
|
||||
|
||||
addJob: (job) =>
|
||||
set((state) => {
|
||||
const jobs = [job, ...state.jobs];
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === 'pending' ||
|
||||
j.status === 'downloading' ||
|
||||
j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
addJob: (job) =>
|
||||
set((state) => {
|
||||
const jobs = [job, ...state.jobs];
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
updateJob: (id, updates) =>
|
||||
set((state) => {
|
||||
const jobs = state.jobs.map((j) =>
|
||||
j.id === id ? { ...j, ...updates } : j
|
||||
);
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === 'pending' ||
|
||||
j.status === 'downloading' ||
|
||||
j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
updateJob: (id, updates) =>
|
||||
set((state) => {
|
||||
const jobs = state.jobs.map((j) => (j.id === id ? { ...j, ...updates } : j));
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
removeJob: (id) =>
|
||||
set((state) => {
|
||||
const jobs = state.jobs.filter((j) => j.id !== id);
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === 'pending' ||
|
||||
j.status === 'downloading' ||
|
||||
j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
removeJob: (id) =>
|
||||
set((state) => {
|
||||
const jobs = state.jobs.filter((j) => j.id !== id);
|
||||
return {
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
),
|
||||
};
|
||||
}),
|
||||
|
||||
setJobs: (jobs) =>
|
||||
set({
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) =>
|
||||
j.status === 'pending' ||
|
||||
j.status === 'downloading' ||
|
||||
j.status === 'transcribing'
|
||||
),
|
||||
}),
|
||||
setJobs: (jobs) =>
|
||||
set({
|
||||
jobs,
|
||||
activeJobs: jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +1,28 @@
|
|||
{
|
||||
"name": "@wisekeep/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"type-check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.12.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
"name": "@wisekeep/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"type-check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.12.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
14
apps/wisekeep/apps/web/src/app.d.ts
vendored
14
apps/wisekeep/apps/web/src/app.d.ts
vendored
|
|
@ -1,13 +1,13 @@
|
|||
// See https://kit.svelte.dev/docs/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
|
|
|||
|
|
@ -3,105 +3,105 @@ import { PUBLIC_API_URL } from '$env/static/public';
|
|||
const API_BASE = PUBLIC_API_URL || 'http://localhost:3006';
|
||||
|
||||
export interface TranscriptionJob {
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status: 'pending' | 'downloading' | 'transcribing' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
id: string;
|
||||
url: string;
|
||||
language: string;
|
||||
provider: string;
|
||||
model?: string;
|
||||
status: 'pending' | 'downloading' | 'transcribing' | 'completed' | 'failed' | 'cancelled';
|
||||
progress: number;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
videoInfo?: {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
thumbnail: string;
|
||||
duration: number;
|
||||
};
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CreateJobRequest {
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: 'openai' | 'local';
|
||||
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: 'openai' | 'local';
|
||||
model?: 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
||||
}
|
||||
|
||||
export interface Playlist {
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
urlCount: number;
|
||||
urls: string[];
|
||||
description?: string;
|
||||
category: string;
|
||||
name: string;
|
||||
path: string;
|
||||
urlCount: number;
|
||||
urls: string[];
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
totalTranscripts: number;
|
||||
totalSizeMB: number;
|
||||
activeJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
totalTranscripts: number;
|
||||
totalSizeMB: number;
|
||||
activeJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
const res = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...options,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
if (!res.ok) {
|
||||
const error = await res.json().catch(() => ({ message: res.statusText }));
|
||||
throw new Error(error.message || 'Request failed');
|
||||
}
|
||||
|
||||
return res.json();
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Transcription
|
||||
createJob: (data: CreateJobRequest) =>
|
||||
request<TranscriptionJob>('/transcription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
// Transcription
|
||||
createJob: (data: CreateJobRequest) =>
|
||||
request<TranscriptionJob>('/transcription', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
|
||||
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
|
||||
|
||||
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
|
||||
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
|
||||
|
||||
cancelJob: (id: string) =>
|
||||
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
|
||||
cancelJob: (id: string) =>
|
||||
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
|
||||
|
||||
getStats: () => request<Stats>('/transcription/stats'),
|
||||
getStats: () => request<Stats>('/transcription/stats'),
|
||||
|
||||
// Playlists
|
||||
getPlaylists: () => request<Playlist[]>('/playlist'),
|
||||
// Playlists
|
||||
getPlaylists: () => request<Playlist[]>('/playlist'),
|
||||
|
||||
getPlaylist: (category: string, name: string) =>
|
||||
request<Playlist>(`/playlist/${category}/${name}`),
|
||||
getPlaylist: (category: string, name: string) =>
|
||||
request<Playlist>(`/playlist/${category}/${name}`),
|
||||
|
||||
createPlaylist: (data: { name: string; description?: string; urls: string[] }) =>
|
||||
request<Playlist>('/playlist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
createPlaylist: (data: { name: string; description?: string; urls: string[] }) =>
|
||||
request<Playlist>('/playlist', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
// Whisper
|
||||
getModels: () =>
|
||||
request<{
|
||||
models: { name: string; size: string; speed: string; accuracy: string }[];
|
||||
defaultProvider: string;
|
||||
openaiAvailable: boolean;
|
||||
}>('/whisper/models'),
|
||||
// Whisper
|
||||
getModels: () =>
|
||||
request<{
|
||||
models: { name: string; size: string; speed: string; accuracy: string }[];
|
||||
defaultProvider: string;
|
||||
openaiAvailable: boolean;
|
||||
}>('/whisper/models'),
|
||||
|
||||
// Health
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
// Health
|
||||
health: () => request<{ status: string }>('/health'),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,95 +9,99 @@ export const jobs: Writable<Map<string, TranscriptionJob>> = writable(new Map())
|
|||
export const isConnected = writable(false);
|
||||
|
||||
export const jobList = derived(jobs, ($jobs) =>
|
||||
Array.from($jobs.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
Array.from($jobs.values()).sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)
|
||||
);
|
||||
|
||||
export const activeJobs = derived(jobList, ($jobs) =>
|
||||
$jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
)
|
||||
$jobs.filter(
|
||||
(j) => j.status === 'pending' || j.status === 'downloading' || j.status === 'transcribing'
|
||||
)
|
||||
);
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
export function initWebSocket() {
|
||||
if (!browser) return;
|
||||
if (!browser) return;
|
||||
|
||||
const connect = () => {
|
||||
socket = new WebSocket(`${WS_URL}/progress`);
|
||||
const connect = () => {
|
||||
socket = new WebSocket(`${WS_URL}/progress`);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('[WebSocket] Connected');
|
||||
isConnected.set(true);
|
||||
};
|
||||
socket.onopen = () => {
|
||||
console.log('[WebSocket] Connected');
|
||||
isConnected.set(true);
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type === 'heartbeat') {
|
||||
return;
|
||||
}
|
||||
if (data.type === 'heartbeat') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'job_update' || data.type === 'job_complete' || data.type === 'job_error') {
|
||||
jobs.update((map) => {
|
||||
const existing = map.get(data.jobId);
|
||||
if (existing) {
|
||||
map.set(data.jobId, {
|
||||
...existing,
|
||||
status: data.status || existing.status,
|
||||
progress: data.progress ?? existing.progress,
|
||||
error: data.error || existing.error,
|
||||
videoInfo: data.videoInfo || existing.videoInfo,
|
||||
transcriptPath: data.transcriptPath || existing.transcriptPath,
|
||||
});
|
||||
}
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Parse error:', e);
|
||||
}
|
||||
};
|
||||
if (
|
||||
data.type === 'job_update' ||
|
||||
data.type === 'job_complete' ||
|
||||
data.type === 'job_error'
|
||||
) {
|
||||
jobs.update((map) => {
|
||||
const existing = map.get(data.jobId);
|
||||
if (existing) {
|
||||
map.set(data.jobId, {
|
||||
...existing,
|
||||
status: data.status || existing.status,
|
||||
progress: data.progress ?? existing.progress,
|
||||
error: data.error || existing.error,
|
||||
videoInfo: data.videoInfo || existing.videoInfo,
|
||||
transcriptPath: data.transcriptPath || existing.transcriptPath,
|
||||
});
|
||||
}
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[WebSocket] Parse error:', e);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected');
|
||||
isConnected.set(false);
|
||||
socket.onclose = () => {
|
||||
console.log('[WebSocket] Disconnected');
|
||||
isConnected.set(false);
|
||||
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeout = setTimeout(connect, 3000);
|
||||
};
|
||||
// Reconnect after 3 seconds
|
||||
reconnectTimeout = setTimeout(connect, 3000);
|
||||
};
|
||||
|
||||
socket.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
};
|
||||
};
|
||||
socket.onerror = (error) => {
|
||||
console.error('[WebSocket] Error:', error);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
connect();
|
||||
}
|
||||
|
||||
export function addJob(job: TranscriptionJob) {
|
||||
jobs.update((map) => {
|
||||
map.set(job.id, job);
|
||||
return new Map(map);
|
||||
});
|
||||
jobs.update((map) => {
|
||||
map.set(job.id, job);
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
export function removeJob(id: string) {
|
||||
jobs.update((map) => {
|
||||
map.delete(id);
|
||||
return new Map(map);
|
||||
});
|
||||
jobs.update((map) => {
|
||||
map.delete(id);
|
||||
return new Map(map);
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanup() {
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
if (reconnectTimeout) {
|
||||
clearTimeout(reconnectTimeout);
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,47 +1,43 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
|
||||
import '../app.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
|
||||
|
||||
onMount(() => {
|
||||
initWebSocket();
|
||||
});
|
||||
onMount(() => {
|
||||
initWebSocket();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup();
|
||||
});
|
||||
onDestroy(() => {
|
||||
cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen flex flex-col">
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-primary-600">
|
||||
Transcriber
|
||||
</a>
|
||||
<nav class="flex items-center gap-6">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900">Dashboard</a>
|
||||
<a href="/transcribe" class="text-gray-600 hover:text-gray-900">Transcribe</a>
|
||||
<a href="/transcripts" class="text-gray-600 hover:text-gray-900">Transcripts</a>
|
||||
<a href="/playlists" class="text-gray-600 hover:text-gray-900">Playlists</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full {$isConnected ? 'bg-green-500' : 'bg-red-500'}"
|
||||
></span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{$isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<header class="bg-white shadow-sm border-b">
|
||||
<div class="max-w-7xl mx-auto px-4 py-4 flex items-center justify-between">
|
||||
<a href="/" class="text-xl font-bold text-primary-600"> Transcriber </a>
|
||||
<nav class="flex items-center gap-6">
|
||||
<a href="/" class="text-gray-600 hover:text-gray-900">Dashboard</a>
|
||||
<a href="/transcribe" class="text-gray-600 hover:text-gray-900">Transcribe</a>
|
||||
<a href="/transcripts" class="text-gray-600 hover:text-gray-900">Transcripts</a>
|
||||
<a href="/playlists" class="text-gray-600 hover:text-gray-900">Playlists</a>
|
||||
</nav>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-2 h-2 rounded-full {$isConnected ? 'bg-green-500' : 'bg-red-500'}"></span>
|
||||
<span class="text-sm text-gray-500">
|
||||
{$isConnected ? 'Connected' : 'Disconnected'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
<main class="flex-1">
|
||||
<slot />
|
||||
</main>
|
||||
|
||||
<footer class="bg-gray-100 border-t py-4">
|
||||
<div class="max-w-7xl mx-auto px-4 text-center text-sm text-gray-500">
|
||||
YouTube Transcriber - AI-powered video transcription
|
||||
</div>
|
||||
</footer>
|
||||
<footer class="bg-gray-100 border-t py-4">
|
||||
<div class="max-w-7xl mx-auto px-4 text-center text-sm text-gray-500">
|
||||
YouTube Transcriber - AI-powered video transcription
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,100 +1,100 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Stats } from '$lib/api/client';
|
||||
import { activeJobs, jobList } from '$lib/stores/jobs';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Stats } from '$lib/api/client';
|
||||
import { activeJobs, jobList } from '$lib/stores/jobs';
|
||||
|
||||
let stats: Stats | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let stats: Stats | null = $state(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
stats = await api.getStats();
|
||||
const jobs = await api.getAllJobs();
|
||||
// Initialize jobs store with existing jobs
|
||||
jobs.forEach((job) => {
|
||||
jobList; // trigger reactivity
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load stats';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
onMount(async () => {
|
||||
try {
|
||||
stats = await api.getStats();
|
||||
const jobs = await api.getAllJobs();
|
||||
// Initialize jobs store with existing jobs
|
||||
jobs.forEach((job) => {
|
||||
jobList; // trigger reactivity
|
||||
});
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load stats';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Transcriber - Dashboard</title>
|
||||
<title>Transcriber - Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
|
||||
<h1 class="text-3xl font-bold mb-8">Dashboard</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
|
||||
{:else if stats}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Total Transcripts</div>
|
||||
<div class="text-3xl font-bold text-primary-600">{stats.totalTranscripts}</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Storage Used</div>
|
||||
<div class="text-3xl font-bold">{stats.totalSizeMB} MB</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Active Jobs</div>
|
||||
<div class="text-3xl font-bold text-yellow-600">{stats.activeJobs}</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Completed</div>
|
||||
<div class="text-3xl font-bold text-green-600">{stats.completedJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
|
||||
{:else if stats}
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Total Transcripts</div>
|
||||
<div class="text-3xl font-bold text-primary-600">{stats.totalTranscripts}</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Storage Used</div>
|
||||
<div class="text-3xl font-bold">{stats.totalSizeMB} MB</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Active Jobs</div>
|
||||
<div class="text-3xl font-bold text-yellow-600">{stats.activeJobs}</div>
|
||||
</div>
|
||||
<div class="bg-white p-6 rounded-lg shadow-sm border">
|
||||
<div class="text-sm text-gray-500 mb-1">Completed</div>
|
||||
<div class="text-3xl font-bold text-green-600">{stats.completedJobs}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Start</h2>
|
||||
<a
|
||||
href="/transcribe"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
Start New Transcription
|
||||
</a>
|
||||
</div>
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6 mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4">Quick Start</h2>
|
||||
<a
|
||||
href="/transcribe"
|
||||
class="inline-flex items-center px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition"
|
||||
>
|
||||
Start New Transcription
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{#if $activeJobs.length > 0}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Active Jobs</h2>
|
||||
<div class="space-y-4">
|
||||
{#each $activeJobs as job (job.id)}
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div class="font-medium">{job.videoInfo?.title || job.url}</div>
|
||||
<div class="text-sm text-gray-500">{job.videoInfo?.channel || 'Loading...'}</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full
|
||||
{#if $activeJobs.length > 0}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Active Jobs</h2>
|
||||
<div class="space-y-4">
|
||||
{#each $activeJobs as job (job.id)}
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<div>
|
||||
<div class="font-medium">{job.videoInfo?.title || job.url}</div>
|
||||
<div class="text-sm text-gray-500">{job.videoInfo?.channel || 'Loading...'}</div>
|
||||
</div>
|
||||
<span
|
||||
class="px-2 py-1 text-xs rounded-full
|
||||
{job.status === 'downloading' ? 'bg-blue-100 text-blue-700' : ''}
|
||||
{job.status === 'transcribing' ? 'bg-yellow-100 text-yellow-700' : ''}
|
||||
{job.status === 'pending' ? 'bg-gray-100 text-gray-700' : ''}"
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style="width: {job.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{job.progress}%</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
>
|
||||
{job.status}
|
||||
</span>
|
||||
</div>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
class="bg-primary-600 h-2 rounded-full transition-all"
|
||||
style="width: {job.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{job.progress}%</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,64 +1,64 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Playlist } from '$lib/api/client';
|
||||
import { onMount } from 'svelte';
|
||||
import { api, type Playlist } from '$lib/api/client';
|
||||
|
||||
let playlists = $state<Playlist[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let playlists = $state<Playlist[]>([]);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
playlists = await api.getPlaylists();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load playlists';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
onMount(async () => {
|
||||
try {
|
||||
playlists = await api.getPlaylists();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load playlists';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const groupedPlaylists = $derived(() => {
|
||||
const grouped: Record<string, Playlist[]> = {};
|
||||
for (const playlist of playlists) {
|
||||
if (!grouped[playlist.category]) {
|
||||
grouped[playlist.category] = [];
|
||||
}
|
||||
grouped[playlist.category].push(playlist);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
const groupedPlaylists = $derived(() => {
|
||||
const grouped: Record<string, Playlist[]> = {};
|
||||
for (const playlist of playlists) {
|
||||
if (!grouped[playlist.category]) {
|
||||
grouped[playlist.category] = [];
|
||||
}
|
||||
grouped[playlist.category].push(playlist);
|
||||
}
|
||||
return grouped;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Playlists - Transcriber</title>
|
||||
<title>Playlists - Transcriber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Playlists</h1>
|
||||
<h1 class="text-3xl font-bold mb-8">Playlists</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
|
||||
{:else if playlists.length === 0}
|
||||
<div class="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-500">No playlists yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.entries(groupedPlaylists()) as [category, categoryPlaylists]}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 capitalize">{category}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each categoryPlaylists as playlist}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||
<h3 class="font-medium">{playlist.name}</h3>
|
||||
{#if playlist.description}
|
||||
<p class="text-sm text-gray-500 mt-1">{playlist.description}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-400 mt-2">{playlist.urlCount} URLs</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg">{error}</div>
|
||||
{:else if playlists.length === 0}
|
||||
<div class="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-500">No playlists yet</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each Object.entries(groupedPlaylists()) as [category, categoryPlaylists]}
|
||||
<div class="mb-8">
|
||||
<h2 class="text-xl font-semibold mb-4 capitalize">{category}</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{#each categoryPlaylists as playlist}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||
<h3 class="font-medium">{playlist.name}</h3>
|
||||
{#if playlist.description}
|
||||
<p class="text-sm text-gray-500 mt-1">{playlist.description}</p>
|
||||
{/if}
|
||||
<p class="text-xs text-gray-400 mt-2">{playlist.urlCount} URLs</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,142 +1,136 @@
|
|||
<script lang="ts">
|
||||
import { api } from '$lib/api/client';
|
||||
import { addJob } from '$lib/stores/jobs';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api/client';
|
||||
import { addJob } from '$lib/stores/jobs';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let url = $state('');
|
||||
let language = $state('de');
|
||||
let provider = $state<'openai' | 'local'>('openai');
|
||||
let model = $state<'tiny' | 'base' | 'small' | 'medium' | 'large'>('base');
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let url = $state('');
|
||||
let language = $state('de');
|
||||
let provider = $state<'openai' | 'local'>('openai');
|
||||
let model = $state<'tiny' | 'base' | 'small' | 'medium' | 'large'>('base');
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
const languages = [
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
];
|
||||
const languages = [
|
||||
{ code: 'de', name: 'German' },
|
||||
{ code: 'en', name: 'English' },
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'fr', name: 'French' },
|
||||
{ code: 'it', name: 'Italian' },
|
||||
{ code: 'pt', name: 'Portuguese' },
|
||||
{ code: 'ja', name: 'Japanese' },
|
||||
{ code: 'ko', name: 'Korean' },
|
||||
{ code: 'zh', name: 'Chinese' },
|
||||
];
|
||||
|
||||
const models = [
|
||||
{ value: 'tiny', label: 'Tiny (39 MB, ~10x speed)' },
|
||||
{ value: 'base', label: 'Base (74 MB, ~7x speed)' },
|
||||
{ value: 'small', label: 'Small (244 MB, ~4x speed)' },
|
||||
{ value: 'medium', label: 'Medium (769 MB, ~2x speed)' },
|
||||
{ value: 'large', label: 'Large (1.5 GB, best accuracy)' },
|
||||
];
|
||||
const models = [
|
||||
{ value: 'tiny', label: 'Tiny (39 MB, ~10x speed)' },
|
||||
{ value: 'base', label: 'Base (74 MB, ~7x speed)' },
|
||||
{ value: 'small', label: 'Small (244 MB, ~4x speed)' },
|
||||
{ value: 'medium', label: 'Medium (769 MB, ~2x speed)' },
|
||||
{ value: 'large', label: 'Large (1.5 GB, best accuracy)' },
|
||||
];
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
async function handleSubmit(e: Event) {
|
||||
e.preventDefault();
|
||||
error = null;
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const job = await api.createJob({
|
||||
url,
|
||||
language,
|
||||
provider,
|
||||
model: provider === 'local' ? model : undefined,
|
||||
});
|
||||
addJob(job);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to start transcription';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const job = await api.createJob({
|
||||
url,
|
||||
language,
|
||||
provider,
|
||||
model: provider === 'local' ? model : undefined,
|
||||
});
|
||||
addJob(job);
|
||||
goto('/');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to start transcription';
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>New Transcription - Transcriber</title>
|
||||
<title>New Transcription - Transcriber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">New Transcription</h1>
|
||||
<h1 class="text-3xl font-bold mb-8">New Transcription</h1>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6">{error}</div>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div class="bg-red-50 text-red-600 p-4 rounded-lg mb-6">{error}</div>
|
||||
{/if}
|
||||
|
||||
<form onsubmit={handleSubmit} class="bg-white rounded-lg shadow-sm border p-6 space-y-6">
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
YouTube URL
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
bind:value={url}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<form onsubmit={handleSubmit} class="bg-white rounded-lg shadow-sm border p-6 space-y-6">
|
||||
<div>
|
||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-2"> YouTube URL </label>
|
||||
<input
|
||||
type="url"
|
||||
id="url"
|
||||
bind:value={url}
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
required
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
id="language"
|
||||
bind:value={language}
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{#each languages as lang}
|
||||
<option value={lang.code}>{lang.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="language" class="block text-sm font-medium text-gray-700 mb-2"> Language </label>
|
||||
<select
|
||||
id="language"
|
||||
bind:value={language}
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{#each languages as lang}
|
||||
<option value={lang.code}>{lang.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Transcription Provider
|
||||
</label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={provider} value="openai" />
|
||||
<span>OpenAI Whisper API</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={provider} value="local" />
|
||||
<span>Local Whisper</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{provider === 'openai'
|
||||
? 'Fast, cloud-based transcription (~$0.006/min)'
|
||||
: 'Free, requires local Whisper installation'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2"> Transcription Provider </label>
|
||||
<div class="flex gap-4">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={provider} value="openai" />
|
||||
<span>OpenAI Whisper API</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="radio" bind:group={provider} value="local" />
|
||||
<span>Local Whisper</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mt-1">
|
||||
{provider === 'openai'
|
||||
? 'Fast, cloud-based transcription (~$0.006/min)'
|
||||
: 'Free, requires local Whisper installation'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if provider === 'local'}
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Whisper Model
|
||||
</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={model}
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{#each models as m}
|
||||
<option value={m.value}>{m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
{#if provider === 'local'}
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-gray-700 mb-2">
|
||||
Whisper Model
|
||||
</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={model}
|
||||
class="w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{#each models as m}
|
||||
<option value={m.value}>{m.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !url}
|
||||
class="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Starting...' : 'Start Transcription'}
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !url}
|
||||
class="w-full py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Starting...' : 'Start Transcription'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,69 +1,70 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { jobList } from '$lib/stores/jobs';
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { jobList } from '$lib/stores/jobs';
|
||||
|
||||
let loading = $state(true);
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
const jobs = await api.getAllJobs();
|
||||
// Jobs are managed via the store
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
onMount(async () => {
|
||||
try {
|
||||
const jobs = await api.getAllJobs();
|
||||
// Jobs are managed via the store
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
const completedJobs = $derived($jobList.filter((j) => j.status === 'completed'));
|
||||
const completedJobs = $derived($jobList.filter((j) => j.status === 'completed'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Transcripts - Transcriber</title>
|
||||
<title>Transcripts - Transcriber</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 py-8">
|
||||
<h1 class="text-3xl font-bold mb-8">Transcripts</h1>
|
||||
<h1 class="text-3xl font-bold mb-8">Transcripts</h1>
|
||||
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if completedJobs.length === 0}
|
||||
<div class="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-500 mb-4">No transcripts yet</p>
|
||||
<a
|
||||
href="/transcribe"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Create your first transcript
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4">
|
||||
{#each completedJobs as job (job.id)}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-medium">{job.videoInfo?.title || 'Untitled'}</h3>
|
||||
<p class="text-sm text-gray-500">{job.videoInfo?.channel || 'Unknown channel'}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Completed: {new Date(job.completedAt || '').toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
</div>
|
||||
{#if job.transcriptText}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-700">
|
||||
View transcript
|
||||
</summary>
|
||||
<pre class="mt-2 p-4 bg-gray-50 rounded text-sm whitespace-pre-wrap overflow-auto max-h-96">
|
||||
{#if loading}
|
||||
<div class="text-gray-500">Loading...</div>
|
||||
{:else if completedJobs.length === 0}
|
||||
<div class="bg-gray-50 rounded-lg p-8 text-center">
|
||||
<p class="text-gray-500 mb-4">No transcripts yet</p>
|
||||
<a
|
||||
href="/transcribe"
|
||||
class="inline-flex items-center px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
|
||||
>
|
||||
Create your first transcript
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid gap-4">
|
||||
{#each completedJobs as job (job.id)}
|
||||
<div class="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-medium">{job.videoInfo?.title || 'Untitled'}</h3>
|
||||
<p class="text-sm text-gray-500">{job.videoInfo?.channel || 'Unknown channel'}</p>
|
||||
<p class="text-xs text-gray-400 mt-1">
|
||||
Completed: {new Date(job.completedAt || '').toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<span class="px-2 py-1 text-xs bg-green-100 text-green-700 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
</div>
|
||||
{#if job.transcriptText}
|
||||
<details class="mt-4">
|
||||
<summary class="cursor-pointer text-sm text-primary-600 hover:text-primary-700">
|
||||
View transcript
|
||||
</summary>
|
||||
<pre
|
||||
class="mt-2 p-4 bg-gray-50 rounded text-sm whitespace-pre-wrap overflow-auto max-h-96">
|
||||
{job.transcriptText}
|
||||
</pre>
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</details>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
|||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter(),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
|
|||
|
|
@ -1,23 +1,23 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
content: ['./src/**/*.{html,js,svelte,ts}'],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#faf5ff',
|
||||
100: '#f3e8ff',
|
||||
200: '#e9d5ff',
|
||||
300: '#d8b4fe',
|
||||
400: '#c084fc',
|
||||
500: '#a855f7',
|
||||
600: '#9333ea',
|
||||
700: '#7e22ce',
|
||||
800: '#6b21a8',
|
||||
900: '#581c87',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,5 +2,5 @@ import { sveltekit } from '@sveltejs/kit/vite';
|
|||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
plugins: [sveltekit()],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
{
|
||||
"default_model": "small",
|
||||
"default_language": "de",
|
||||
"models": {
|
||||
"tiny": {
|
||||
"size_mb": 39,
|
||||
"speed": "~10x Echtzeit",
|
||||
"accuracy": "75%"
|
||||
},
|
||||
"base": {
|
||||
"size_mb": 74,
|
||||
"speed": "~7x Echtzeit",
|
||||
"accuracy": "85%"
|
||||
},
|
||||
"small": {
|
||||
"size_mb": 244,
|
||||
"speed": "~4x Echtzeit",
|
||||
"accuracy": "91%"
|
||||
},
|
||||
"medium": {
|
||||
"size_mb": 769,
|
||||
"speed": "~2x Echtzeit",
|
||||
"accuracy": "94%"
|
||||
},
|
||||
"large": {
|
||||
"size_mb": 1550,
|
||||
"speed": "~1x Echtzeit",
|
||||
"accuracy": "96-98%"
|
||||
}
|
||||
}
|
||||
}
|
||||
"default_model": "small",
|
||||
"default_language": "de",
|
||||
"models": {
|
||||
"tiny": {
|
||||
"size_mb": 39,
|
||||
"speed": "~10x Echtzeit",
|
||||
"accuracy": "75%"
|
||||
},
|
||||
"base": {
|
||||
"size_mb": 74,
|
||||
"speed": "~7x Echtzeit",
|
||||
"accuracy": "85%"
|
||||
},
|
||||
"small": {
|
||||
"size_mb": 244,
|
||||
"speed": "~4x Echtzeit",
|
||||
"accuracy": "91%"
|
||||
},
|
||||
"medium": {
|
||||
"size_mb": 769,
|
||||
"speed": "~2x Echtzeit",
|
||||
"accuracy": "94%"
|
||||
},
|
||||
"large": {
|
||||
"size_mb": 1550,
|
||||
"speed": "~1x Echtzeit",
|
||||
"accuracy": "96-98%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
{
|
||||
"name": "wisekeep",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep - AI-powered wisdom extraction from video content",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @wisekeep/backend dev",
|
||||
"dev:web": "pnpm --filter @wisekeep/web dev",
|
||||
"dev:landing": "pnpm --filter @wisekeep/landing dev",
|
||||
"dev:mobile": "pnpm --filter @wisekeep/mobile dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^1.13.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
"name": "wisekeep",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep - AI-powered wisdom extraction from video content",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @wisekeep/backend dev",
|
||||
"dev:web": "pnpm --filter @wisekeep/web dev",
|
||||
"dev:landing": "pnpm --filter @wisekeep/landing dev",
|
||||
"dev:mobile": "pnpm --filter @wisekeep/mobile dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^1.13.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"name": "@wisekeep/shared-types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
"name": "@wisekeep/shared-types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
// Transcription Job Types
|
||||
export type JobStatus =
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'transcribing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'transcribing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
export type WhisperProvider = 'groq' | 'local';
|
||||
|
||||
|
|
@ -14,79 +14,79 @@ export type LocalWhisperModel = 'tiny' | 'base' | 'small' | 'medium' | 'large';
|
|||
export type WhisperModel = GroqWhisperModel | LocalWhisperModel;
|
||||
|
||||
export interface VideoInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
duration: number;
|
||||
thumbnail?: string;
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
duration: number;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface TranscriptionJob {
|
||||
id: string;
|
||||
url: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
language: string;
|
||||
provider: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
videoInfo?: VideoInfo;
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
id: string;
|
||||
url: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
language: string;
|
||||
provider: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
videoInfo?: VideoInfo;
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface TranscribeRequest {
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
}
|
||||
|
||||
export interface TranscriptionStats {
|
||||
totalTranscripts: number;
|
||||
totalSizeMB: number;
|
||||
activeJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
totalTranscripts: number;
|
||||
totalSizeMB: number;
|
||||
activeJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
}
|
||||
|
||||
// WebSocket Event Types
|
||||
export interface JobUpdateEvent {
|
||||
type: 'job_update';
|
||||
jobId: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
videoInfo?: VideoInfo;
|
||||
type: 'job_update';
|
||||
jobId: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
videoInfo?: VideoInfo;
|
||||
}
|
||||
|
||||
export interface JobCompleteEvent {
|
||||
type: 'job_complete';
|
||||
jobId: string;
|
||||
status: 'completed';
|
||||
transcriptPath: string;
|
||||
type: 'job_complete';
|
||||
jobId: string;
|
||||
status: 'completed';
|
||||
transcriptPath: string;
|
||||
}
|
||||
|
||||
export interface JobErrorEvent {
|
||||
type: 'job_error';
|
||||
jobId: string;
|
||||
error: string;
|
||||
type: 'job_error';
|
||||
jobId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type WebSocketEvent = JobUpdateEvent | JobCompleteEvent | JobErrorEvent;
|
||||
|
||||
// Playlist Types
|
||||
export interface Playlist {
|
||||
name: string;
|
||||
category: string;
|
||||
urls: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
name: string;
|
||||
category: string;
|
||||
urls: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
category: string;
|
||||
name: string;
|
||||
urlCount: number;
|
||||
category: string;
|
||||
name: string;
|
||||
urlCount: number;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["node_modules", "dist", "build"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue