feat(transcriber): Add YouTube transcriber app to monorepo

Integrate new transcriber application for AI-powered YouTube video
transcription with full monorepo structure and Groq Whisper API support.

## App Structure
- apps/transcriber/apps/backend - NestJS API server (port 3006)
- apps/transcriber/apps/web - SvelteKit web application
- apps/transcriber/apps/landing - Astro marketing/content site
- apps/transcriber/apps/mobile - Expo React Native app
- apps/transcriber/packages/shared-types - Shared TypeScript types

## Backend Features
- YouTube video download via yt-dlp (child_process)
- Ultra-fast transcription via Groq Whisper API (~300x realtime)
- Fallback to local Whisper for offline use
- Job queue with background processing
- Real-time progress updates via WebSocket (Socket.io)
- Playlist management for batch processing
- Health check endpoints

## API Endpoints
- POST /transcription - Start transcription job
- GET /transcription - List all jobs
- GET /transcription/:id - Get job status
- DELETE /transcription/:id - Cancel job
- GET /transcription/stats - Statistics
- GET /whisper/models - Available models
- GET/POST/DELETE /playlist - Playlist management
- GET /health - Health checks

## Whisper Models
- Groq: whisper-large-v3-turbo (fast, $0.04/hr)
- Groq: whisper-large-v3 (accurate, $0.111/hr)
- Local: tiny, base, small, medium, large

## Monorepo Integration
- Added to pnpm workspace via apps/*/apps/* pattern
- Root scripts: transcriber:dev, dev:transcriber:*
- Package naming: @transcriber/{backend,web,landing,mobile}
- Turbo tasks: dev, build, lint, type-check
- CLAUDE.md documentation

## Technology Stack
- Backend: NestJS 10, TypeScript, Socket.io
- Web: SvelteKit 2, Svelte 5, Tailwind CSS
- Landing: Astro 4, Solid.js, Tailwind CSS
- Mobile: Expo 52, React Native, NativeWind, Zustand
- Transcription: Groq Whisper API (OpenAI-compatible)

## Migration from Python
- Original Python/FastAPI code preserved in legacy/
- Full rewrite to TypeScript/NestJS
- Same functionality with improved architecture

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 14:21:49 +01:00
parent 98efa6f6e8
commit 4b08c41547
114 changed files with 19558 additions and 533 deletions

53
apps/transcriber/.gitignore vendored Normal file
View file

@ -0,0 +1,53 @@
# Python (legacy)
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
venv/
env/
ENV/
# Data (transcripts, playlists)
data/
# Node
node_modules/
.npm
dist/
build/
.astro/
.svelte-kit/
.turbo/
# IDE
.vscode/
.idea/
*.swp
*.swo
.DS_Store
# Environment
.env
.env.local
.env.*.local
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
apps/*/dist/
apps/*/.astro/
apps/*/.svelte-kit/
# Expo
apps/mobile/.expo/
apps/mobile/ios/
apps/mobile/android/
# Legacy
legacy/venv/
legacy/__pycache__/

217
apps/transcriber/CLAUDE.md Normal file
View file

@ -0,0 +1,217 @@
# CLAUDE.md - Transcriber
This file provides guidance to Claude Code when working with the Transcriber project.
## Project Overview
Transcriber is an AI-powered YouTube video transcription application with:
- YouTube video download via yt-dlp
- Ultra-fast audio transcription using Groq Whisper API (~300x realtime)
- Fallback to local Whisper for offline use
- Playlist management for batch processing
- Real-time progress updates via WebSocket
- Multi-platform support (Web, Mobile, Landing)
## Architecture
```
apps/transcriber/
├── apps/
│ ├── backend/ # NestJS API server (port 3006)
│ ├── web/ # SvelteKit web application
│ ├── landing/ # Astro landing/content site
│ └── mobile/ # Expo React Native app
├── packages/
│ └── shared-types/ # Shared TypeScript types
├── data/ # Transcripts & playlists (gitignored)
├── legacy/ # Original Python code (reference)
├── package.json # Root orchestrator
└── CLAUDE.md # This file
```
## Quick Start
### Prerequisites
- Node.js 20+
- pnpm 9.15.0+
- yt-dlp installed (`brew install yt-dlp` on macOS)
- For local Whisper: Python 3 with openai-whisper package
### Development
```bash
# From monorepo root
pnpm install
# Start all transcriber apps
pnpm transcriber:dev
# Start individual apps
pnpm dev:transcriber:backend # NestJS backend (port 3006)
pnpm dev:transcriber:web # SvelteKit web (port 5173)
pnpm dev:transcriber:landing # Astro landing (port 4321)
pnpm dev:transcriber:mobile # Expo mobile
# Start web + backend together
pnpm dev:transcriber:app
```
### Environment Variables
Create `apps/transcriber/apps/backend/.env`:
```bash
PORT=3006
WHISPER_PROVIDER=groq # groq or local
WHISPER_MODEL=whisper-large-v3-turbo # whisper-large-v3-turbo, whisper-large-v3 (groq) | tiny, base, small, medium, large (local)
GROQ_API_KEY=gsk_... # Required for Groq provider
TEMP_AUDIO_DIR=./temp_audio
TRANSCRIPTS_DIR=./data/transcripts
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 |
### 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 |
### Whisper
| 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 |
## WebSocket
Connect to `/progress` namespace for real-time updates:
```typescript
const socket = io('http://localhost:3006/progress');
socket.on('job_update', (data) => {
// { type, jobId, status, progress, videoInfo }
});
socket.on('job_complete', (data) => {
// { type, jobId, status, transcriptPath }
});
socket.on('job_error', (data) => {
// { 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
- Models: `whisper-large-v3-turbo` (fast) or `whisper-large-v3` (accurate)
- Set `WHISPER_PROVIDER=groq` and `GROQ_API_KEY`
### Local Whisper
- Free, runs locally
- Requires Python + openai-whisper
- GPU recommended for larger models
- Models: `tiny`, `base`, `small`, `medium`, `large`
- Set `WHISPER_PROVIDER=local` and `WHISPER_MODEL`
## 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 |
## Code Patterns
### Backend Services
```typescript
@Injectable()
export class TranscriptionService {
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'));
// Wrong - Old Svelte syntax
let jobs = [];
$: 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] })),
}));
```
## 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
## Troubleshooting
### yt-dlp not found
```bash
# macOS
brew install yt-dlp
# Linux
pip install yt-dlp
```
### Local Whisper not working
```bash
# Install Whisper
pip install openai-whisper
# Test
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)
# Check environment
cat apps/backend/.env
```

392
apps/transcriber/README.md Normal file
View file

@ -0,0 +1,392 @@
# 🎥 YouTube Transcriber System
Ein vollständiges System zur automatischen Transkription, Aufbereitung und Präsentation von YouTube-Videos mit OpenAI's Whisper, FastAPI Backend und Astro.js Frontend.
## ✨ System-Komponenten
### 🔧 Backend (Python)
- **OpenAI Whisper** - Lokale Speech-to-Text Transkription
- **FastAPI Server** - REST API für Web-Interface
- **Parallel Processing** - Bis zu 3.3x schnellere Verarbeitung
- **Playlist Management** - Automatische Batch-Verarbeitung
### 🌐 Frontend (Astro.js)
- **Public Website** - Aufbereitete Vorträge als Wisdom Library
- **Admin Panel** - Transkriptions-Management (localhost only)
- **Content Collections** - Strukturierte Inhalte mit Markdown
- **Responsive Design** - Optimiert für alle Geräte
## 🏗️ Architektur
```
YoutubeDL/
├── 🐍 Python Backend
│ ├── transcriber_v4_parallel.py # Parallel-Verarbeitung
│ ├── api_server.py # FastAPI REST API
│ └── playlists/ # YouTube URL-Listen
├── 🌐 Website
│ ├── src/pages/ # Public & Admin Pages
│ ├── src/content/talks/ # Aufbereitete Vorträge
│ └── src/components/admin/ # Admin-Komponenten
└── 📂 Output
└── transcripts/ # Transkribierte Texte
```
## 🛠 Installation
### Voraussetzungen
- Python 3.10+
- FFmpeg
- macOS (optimiert für Apple Silicon M1/M2)
### Setup
1. **Repository klonen:**
```bash
git clone https://github.com/yourusername/youtube-transcriber.git
cd youtube-transcriber
```
2. **Virtual Environment erstellen:**
```bash
python3 -m venv venv
source venv/bin/activate
```
3. **Dependencies installieren:**
```bash
pip install -r requirements.txt
```
## 🚀 Schnellstart
### Kompletter Workflow: Von YouTube zu Website
#### 1. Speaker Content sammeln
Erstelle eine Playlist für einen Speaker (z.B. Simon Sinek):
```bash
# playlists/people/simon-sinek.txt erstellen
# Simon Sinek Videos
# Popular talks and interviews from YouTube
# Created: 2025-09-09
# TED Talks
# How great leaders inspire action (Start with Why) - 60M+ views
https://www.youtube.com/watch?v=u4ZoJKF_VuA
# Why good leaders make you feel safe - 18M+ views
https://www.youtube.com/watch?v=lmyZMtPVodo
```
#### 2. Videos transkribieren
```bash
# Virtual Environment aktivieren
source venv/bin/activate
# Parallel-Verarbeitung starten (3-4x schneller)
python3 transcriber_v4_parallel.py --playlist playlists/people/simon-sinek.txt --model base --language en
```
#### 3. Website Content erstellen
**a) Content Schema erweitern** (wenn neue Kategorie):
```typescript
// website/src/content/config.ts
category: z.enum([
'behavioral-economics',
'psychology',
'leadership', // Neue Kategorie hinzufügen
// ...
]),
```
**b) Speaker Profil erstellen**:
```bash
# website/src/pages/speakers/simon-sinek.astro
```
**c) Talk-Seiten erstellen**:
```bash
# Für jedes erfolgreich transkribierte Video:
# website/src/content/talks/simon-sinek-[talk-slug].md
```
**d) SearchableContentList aktualisieren**:
```typescript
// website/src/components/SearchableContentList.tsx
// Neue Talks zur Inhaltsliste hinzufügen
```
#### 4. Website starten
```bash
cd website
npm run dev
```
### Einzelnes Video transkribieren
```bash
# Mit Large-Modell (beste Qualität)
python3 transcriber_v3.py process "https://www.youtube.com/watch?v=VIDEO_ID" --model large
# Mit Tiny-Modell (schneller Test)
python3 transcriber_v3.py process "https://www.youtube.com/watch?v=VIDEO_ID" --model tiny
```
### Playlists verwalten
1. **Playlist erstellen:**
- Erstelle eine `.txt` Datei im `playlists/` Ordner
- Füge YouTube-URLs ein (eine pro Zeile)
```bash
# playlists/tech/python_tutorials.txt
https://www.youtube.com/watch?v=VIDEO_ID1
https://www.youtube.com/watch?v=VIDEO_ID2
```
2. **Alle Playlists scannen:**
```bash
python3 transcriber_v3.py scan --model large
```
3. **Spezifische Playlist verarbeiten:**
```bash
python3 transcriber_v3.py scan --playlist tech/python_tutorials
```
### Quick-Script verwenden
```bash
./quick_transcribe.sh
```
Bietet ein interaktives Menü zur Modell-Auswahl.
## 📂 Projektstruktur
```
YoutubeDL/
├── playlists/ # YouTube URL-Listen nach Themen
│ ├── tech/
│ │ └── python_tutorials.txt
│ ├── people/
│ │ └── rory-sutherland.txt
│ └── musik/
│ └── klassik.txt
├── transcripts/ # Transkribierte Texte (automatisch organisiert)
│ ├── tech_python_tutorials/
│ │ └── [Kanal]/
│ │ └── [Video]_[Timestamp].txt
│ └── people_rory-sutherland/
│ └── TED/
├── .cache/ # Cache für bereits verarbeitete Videos
├── temp_audio/ # Temporäre Audio-Dateien
├── venv/ # Python Virtual Environment
├── transcriber.py # v1: Basis-Funktionalität
├── transcriber_v2.py # v2: Mit Rich UI
├── transcriber_v3.py # v3: Mit Playlist-Management
└── quick_transcribe.sh # Schnellzugriff-Script
```
## 🎯 Whisper-Modelle
| Modell | Größe | Geschwindigkeit | Genauigkeit | Verwendung |
|--------|-------|-----------------|-------------|------------|
| **tiny** | 39 MB | ~10x Echtzeit | 75% | Schnelle Tests |
| **base** | 74 MB | ~7x Echtzeit | 85% | Guter Kompromiss |
| **small** | 244 MB | ~4x Echtzeit | 91% | Solide Qualität |
| **medium** | 769 MB | ~2x Echtzeit | 94% | Hohe Qualität |
| **large** | 1.5 GB | ~1x Echtzeit | 96-98% | Beste Qualität |
## 📋 Befehle
### Hauptbefehle
```bash
# Zeige alle Playlists
python3 transcriber_v3.py list
# Verarbeite alle neuen Videos in allen Playlists
python3 transcriber_v3.py scan
# Verarbeite einzelnes Video
python3 transcriber_v3.py process "URL"
# Mit spezifischem Modell
python3 transcriber_v3.py scan --model large
# Andere Sprache
python3 transcriber_v3.py scan --language en
```
### Optionen
- `--model {tiny,base,small,medium,large}` - Whisper-Modell auswählen
- `--language LANG` - Sprache setzen (default: de)
- `--playlist NAME` - Spezifische Playlist verarbeiten
- `--output DIR` - Ausgabe-Verzeichnis (default: transcripts)
- `--force` - Cache ignorieren und neu transkribieren
## 🔄 Automatisierung
### Cron-Job einrichten
Für tägliche automatische Verarbeitung:
```bash
# Crontab öffnen
crontab -e
# Täglich um 3 Uhr nachts alle Playlists scannen
0 3 * * * cd /path/to/YoutubeDL && source venv/bin/activate && python3 transcriber_v3.py scan --model large
```
## 💡 Tipps
1. **Organisiere nach Themen**: Erstelle Unterordner in `playlists/` für verschiedene Themen
2. **Cache nutzen**: Das System merkt sich bereits transkribierte Videos automatisch
3. **Modell-Auswahl**:
- Nutze `tiny` für schnelle Tests
- Nutze `large` für wichtige Transkriptionen
4. **Batch-Verarbeitung**: Füge alle URLs zur Playlist hinzu und lasse über Nacht laufen
## 🎨 Features im Detail
### Rich Terminal UI (v2+)
- Farbige Ausgabe mit Emojis
- Progress Bars für Download und Transkription
- Zeitschätzungen basierend auf Video-Länge
- Video-Metadaten vor Download
### Playlist-Management (v3)
- Automatisches Scannen von URL-Listen
- Themen-basierte Organisation
- Nur neue Videos werden verarbeitet
- Batch-Verarbeitung mehrerer Playlists
### Cache-System
- Verhindert doppelte Verarbeitung
- Speichert Metadaten zu transkribierten Videos
- `.cache/transcribed_videos.json` enthält Historie
## 🐛 Troubleshooting
**FFmpeg nicht gefunden:**
```bash
# macOS
brew install ffmpeg
```
**Whisper-Modell lädt sehr lange:**
- Beim ersten Mal wird das Modell heruntergeladen
- Large: ~1.5GB, kann 10-30 Minuten dauern
**"Video bereits transkribiert":**
- Nutze `--force` Flag zum Überschreiben
- Oder lösche `.cache/` Ordner für kompletten Reset
## 📈 Performance (Apple Silicon M1)
- **Tiny**: ~10x Echtzeit (6 Min Video → 36 Sek)
- **Base**: ~7x Echtzeit (6 Min Video → 50 Sek)
- **Small**: ~4x Echtzeit (6 Min Video → 1.5 Min)
- **Large**: ~1x Echtzeit (6 Min Video → 6 Min)
## 🔒 Datenschutz
- Alle Verarbeitung erfolgt **lokal** auf deinem Computer
- Keine Daten werden an externe Server gesendet
- Whisper läuft komplett offline
## 📝 Lizenz
MIT License - Siehe LICENSE Datei
## 🙏 Credits
- **OpenAI Whisper** - Speech-to-Text Engine
- **yt-dlp** - YouTube Download Tool
- **Rich** - Terminal UI Library
- **FFmpeg** - Audio/Video Verarbeitung
## 🌐 Website Integration
Das System generiert nicht nur Transkripte, sondern auch eine vollständige Website mit den aufbereiteten Inhalten.
### Website-Features
- **📚 Content Collections**: Strukturierte Talk-Seiten mit Markdown
- **🔍 Suchfunktion**: Volltextsuche über alle Talks
- **👤 Speaker Profile**: Übersichtsseiten für jeden Speaker
- **🏷️ Tag-System**: Kategorisierung nach Themen
- **📱 Responsive**: Optimiert für alle Geräte
- **🎨 Theming**: Verschiedene Farbschemata
### Content-Struktur
```
website/src/
├── content/
│ ├── config.ts # Content Schema
│ └── talks/ # Aufbereitete Talk-Seiten
│ ├── simon-sinek-why-good-leaders-make-you-feel-safe.md
│ ├── simon-sinek-millennials-in-the-workplace.md
│ └── simon-sinek-love-your-work.md
├── pages/
│ ├── speakers/
│ │ ├── index.astro # Speaker-Übersicht
│ │ └── simon-sinek.astro # Speaker-Profile
│ └── talks/
│ └── [slug].astro # Dynamische Talk-Seiten
└── components/
├── SearchableContentList.tsx # Hauptsuche
├── ContentCard.tsx # Talk-Vorschau
└── speakers/
├── SpeakerHero.astro # Speaker-Header
├── TalkGrid.astro # Talk-Grid
└── QuoteCollection.astro # Zitate-Sammlung
```
### Website entwickeln
```bash
# Website Dependencies installieren
cd website
npm install
# Entwicklungsserver starten
npm run dev
# Website bauen für Produktion
npm run build
```
### Content-Erstellung Workflow
1. **Transkription**: Videos mit Python-Backend transkribieren
2. **Content-Aufbereitung**: Markdown-Dateien mit Metadaten erstellen
3. **Speaker-Profile**: Übersichtsseiten für neue Speaker
4. **Integration**: Neue Inhalte in Suchfunktion einbinden
5. **Deployment**: Website bauen und deployen
## 🚧 Roadmap
- [x] **Parallel Processing** - 3-4x schnellere Transkription
- [x] **Website Integration** - Vollständige Content-Website
- [x] **Speaker Profiles** - Detaillierte Speaker-Übersichten
- [x] **Content Collections** - Strukturierte Talk-Aufbereitung
- [ ] **Admin Interface** - Web-UI für Transkriptions-Management
- [ ] **Speaker Diarization** - Wer spricht wann
- [ ] **Automatische Zusammenfassungen** - LLM-basierte Summaries
- [ ] **Export Formate** - SRT, VTT, JSON Export
- [ ] **YouTube Playlist Auto-Import** - Direkte Playlist-Integration
---
**Entwickelt mit ❤️ für automatische Transkription**

View file

@ -0,0 +1,14 @@
# Server
PORT=3006
# Whisper Configuration
WHISPER_PROVIDER=openai # openai or local
WHISPER_MODEL=base # tiny, base, small, medium, large (for local)
# OpenAI API (for cloud transcription)
OPENAI_API_KEY=sk-your-openai-api-key
# Directories
TEMP_AUDIO_DIR=./temp_audio
TRANSCRIPTS_DIR=./data/transcripts
PLAYLISTS_DIR=./data/playlists

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,52 @@
{
"name": "@transcriber/backend",
"version": "1.0.0",
"private": true,
"description": "YouTube Transcriber Backend - NestJS API",
"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"
}
}

View file

@ -0,0 +1,24 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { TranscriptionModule } from './transcription/transcription.module';
import { PlaylistModule } from './playlist/playlist.module';
import { YoutubeModule } from './youtube/youtube.module';
import { WhisperModule } from './whisper/whisper.module';
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,
],
})
export class AppModule {}

View file

@ -0,0 +1,30 @@
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('ready')
ready() {
return {
status: 'ready',
timestamp: new Date().toISOString(),
};
}
@Get('live')
live() {
return {
status: 'alive',
timestamp: new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,31 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
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.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
const port = process.env.PORT || 3006;
await app.listen(port);
console.log(`[Transcriber Backend] Running on http://localhost:${port}`);
}
bootstrap();

View file

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

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PlaylistController } from './playlist.controller';
import { PlaylistService } from './playlist.service';
@Module({
controllers: [PlaylistController],
providers: [PlaylistService],
exports: [PlaylistService],
})
export class PlaylistModule {}

View file

@ -0,0 +1,176 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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;
}
export interface CreatePlaylistDto {
name: string;
description?: string;
urls: string[];
}
@Injectable()
export class PlaylistService {
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';
// Ensure playlists directory exists
if (!fs.existsSync(this.playlistsDir)) {
fs.mkdirSync(this.playlistsDir, { recursive: true });
}
}
async getAll(): Promise<Playlist[]> {
const playlists: Playlist[] = [];
if (!fs.existsSync(this.playlistsDir)) {
return playlists;
}
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 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[] = [];
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,
});
}
}
return playlists;
}
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`);
}
const content = fs.readFileSync(filePath, 'utf-8');
const lines = content.split('\n');
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);
}
}
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;
const categoryDir = path.join(this.playlistsDir, category);
if (!fs.existsSync(categoryDir)) {
fs.mkdirSync(categoryDir, { recursive: true });
}
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';
fs.writeFileSync(filePath, content, 'utf-8');
this.logger.log(`Created playlist: ${category}/${name}`);
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`);
if (!fs.existsSync(filePath)) {
throw new NotFoundException(`Playlist ${category}/${name} not found`);
}
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);
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');
playlist.urlCount = playlist.urls.length;
return playlist;
}
}

View file

@ -0,0 +1,35 @@
import { IsString, IsOptional, IsUrl, IsEnum } from 'class-validator';
export enum WhisperProviderEnum {
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',
}
export class TranscribeRequestDto {
@IsUrl()
url: string;
@IsString()
@IsOptional()
language?: string = 'de';
@IsEnum(WhisperProviderEnum)
@IsOptional()
provider?: WhisperProviderEnum;
@IsEnum(WhisperModelEnum)
@IsOptional()
model?: WhisperModelEnum;
}

View file

@ -0,0 +1,52 @@
export enum JobStatus {
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;
}
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;
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();
}
}

View file

@ -0,0 +1,40 @@
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) {}
@Post()
async createJob(@Body() dto: TranscribeRequestDto) {
return this.transcriptionService.createJob(dto);
}
@Get()
async getAllJobs() {
return this.transcriptionService.getAllJobs();
}
@Get('stats')
async getStats() {
return this.transcriptionService.getStats();
}
@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);
}
}

View file

@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TranscriptionController } from './transcription.controller';
import { TranscriptionService } from './transcription.service';
import { YoutubeModule } from '../youtube/youtube.module';
import { WhisperModule } from '../whisper/whisper.module';
import { WebsocketModule } from '../websocket/websocket.module';
@Module({
imports: [YoutubeModule, WhisperModule, WebsocketModule],
controllers: [TranscriptionController],
providers: [TranscriptionService],
exports: [TranscriptionService],
})
export class TranscriptionModule {}

View file

@ -0,0 +1,267 @@
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
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 { 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;
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 });
}
}
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);
// Start processing in background
this.processJob(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 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`);
}
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,
});
}
return job;
}
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;
};
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);
this.logger.log(`Processing: ${videoInfo.title}`);
// 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));
});
this.updateJobProgress(job, JobStatus.DOWNLOADING, 50);
// Check if cancelled
if (isCancelled()) {
if (audioPath) await this.youtubeService.cleanupFile(audioPath);
return;
}
// 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,
);
this.updateJobProgress(job, JobStatus.TRANSCRIBING, 90);
// 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,
);
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.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.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;
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);
const channelDir = path.join(this.transcriptsDir, sanitize(videoInfo.channel));
if (!fs.existsSync(channelDir)) {
fs.mkdirSync(channelDir, { recursive: true });
}
const filename = `${sanitize(videoInfo.title)}_${videoInfo.id}.txt`;
const filePath = path.join(channelDir, filename);
const content = `# ${videoInfo.title}
Channel: ${videoInfo.channel}
Video ID: ${videoInfo.id}
Language: ${job.language}
Transcribed: ${new Date().toISOString()}
Provider: ${job.provider}
---
${text}
`;
fs.writeFileSync(filePath, content, 'utf-8');
return filePath;
}
async getStats() {
const jobs = Array.from(this.jobs.values());
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);
}
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,
};
}
}

View file

@ -0,0 +1,85 @@
import {
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;
}
@WebSocketGateway({
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);
@WebSocketServer()
server: Server;
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);
client.on('disconnect', () => {
clearInterval(interval);
});
}
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(),
});
}
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(),
});
}
}

View file

@ -0,0 +1,9 @@
import { Module, Global } from '@nestjs/common';
import { ProgressGateway } from './progress.gateway';
@Global()
@Module({
providers: [ProgressGateway],
exports: [ProgressGateway],
})
export class WebsocketModule {}

View file

@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { WhisperService } from './whisper.service';
@Controller('whisper')
export class WhisperController {
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(),
};
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { WhisperService } from './whisper.service';
import { WhisperController } from './whisper.controller';
@Module({
controllers: [WhisperController],
providers: [WhisperService],
exports: [WhisperService],
})
export class WhisperModule {}

View file

@ -0,0 +1,235 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import * as fs from 'fs';
import OpenAI from 'openai';
export type WhisperProvider = 'groq' | 'local';
export type GroqWhisperModel = 'whisper-large-v3-turbo' | 'whisper-large-v3';
export type LocalWhisperModel = 'tiny' | 'base' | 'small' | 'medium' | 'large';
export type WhisperModel = GroqWhisperModel | LocalWhisperModel;
export interface TranscriptionResult {
text: string;
language: string;
duration: number;
provider: WhisperProvider;
}
export interface WhisperModelInfo {
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;
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.',
);
}
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;
// 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,
);
}
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');
}
this.logger.log(
`Transcribing with Groq Whisper API (${model}): ${audioPath}`,
);
const startTime = Date.now();
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;
this.logger.log(`Groq transcription completed in ${duration.toFixed(2)}s`);
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}`,
);
const startTime = Date.now();
return new Promise((resolve, reject) => {
// Python script to run Whisper
const pythonScript = `
import whisper
import json
import sys
model = whisper.load_model("${model}")
result = model.transcribe("${audioPath}", language="${language}")
print(json.dumps({"text": result["text"], "language": result.get("language", "${language}")}))
`.trim();
const python = spawn('python3', ['-c', pythonScript]);
let stdout = '';
let stderr = '';
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.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;
}
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[] = [];
// 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%' },
);
return models;
}
isGroqAvailable(): boolean {
return this.groqClient !== null;
}
getDefaultProvider(): WhisperProvider {
return this.defaultProvider;
}
getDefaultModel(): WhisperModel {
return this.defaultModel;
}
}

View file

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { YoutubeService } from './youtube.service';
@Module({
providers: [YoutubeService],
exports: [YoutubeService],
})
export class YoutubeModule {}

View file

@ -0,0 +1,166 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { spawn } from 'child_process';
import * as path from 'path';
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;
}
export interface DownloadProgress {
percent: number;
speed: string;
eta: string;
}
@Injectable()
export class YoutubeService {
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';
// 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]);
let stdout = '';
let stderr = '';
ytdlp.stdout.on('data', (data) => {
stdout += 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;
}
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`);
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 = '';
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],
});
}
});
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;
}
// 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;
}
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}`);
}
}
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));
}
}

View file

@ -0,0 +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"]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from 'astro/config';
import solidJs from '@astrojs/solid-js';
import tailwind from '@astrojs/tailwind';
// https://astro.build/config
export default defineConfig({
integrations: [
solidJs(),
tailwind()
]
});

View file

@ -0,0 +1,27 @@
{
"name": "@transcriber/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"
}
}

View file

@ -0,0 +1,162 @@
---
export interface Props {
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, '');
---
<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>
</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: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:hover {
background: rgba(var(--theme-primary), 0.05);
}
.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-arrow {
flex-shrink: 0;
transition: transform 0.3s ease;
color: rgb(var(--theme-primary));
}
.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[data-collapsed="true"] {
max-height: 0;
}
.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;
}
}
</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>

View file

@ -0,0 +1,103 @@
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;
}
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>
);
};
export default ContentCard;

View file

@ -0,0 +1,151 @@
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;
}
const ContentCardList: Component = () => {
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);
});
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;

View file

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

View file

@ -0,0 +1,108 @@
---
export interface Props {
currentPath?: string;
}
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>
<script>
const mobileMenuButton = document.getElementById('mobile-menu-button');
const mobileMenu = document.getElementById('mobile-menu');
mobileMenuButton?.addEventListener('click', () => {
mobileMenu?.classList.toggle('hidden');
});
</script>

View file

@ -0,0 +1,322 @@
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;
}
const SearchableContentList: Component = () => {
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);
});
// 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))
);
});
});
// Handle search input
const handleSearch = (e: Event) => {
const target = e.target as HTMLInputElement;
setSearchQuery(target.value);
};
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;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`}</style>
</div>
);
};
export default SearchableContentList;

View file

@ -0,0 +1,309 @@
---
import { getCollection } from 'astro:content';
const talks = await getCollection('talks');
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();
});
// 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>);
---
<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="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>
</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-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
}
.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;
}
.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:hover {
background: rgba(var(--theme-primary), 0.08);
color: rgb(var(--theme-primary));
}
.nav-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.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 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:hover {
background: rgba(var(--theme-primary), 0.2);
transform: scale(1.1);
}
.talks-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.talks-list::-webkit-scrollbar {
width: 6px;
}
.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:hover {
background: rgba(var(--theme-primary), 0.3);
}
.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;
}
.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: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-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-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-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;
}
.sidebar.open {
transform: translateX(0);
}
}
</style>

View file

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

View file

@ -0,0 +1,225 @@
import { createSignal, createEffect, onMount, For } from 'solid-js';
interface Job {
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;
}
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);
onMount(() => {
fetchJobs();
fetchStats();
connectWebSocket();
});
const connectWebSocket = () => {
const websocket = new WebSocket(`ws://localhost:8000/ws/progress`);
websocket.onopen = () => {
console.log('WebSocket connected');
};
websocket.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'job_update' || data.type === 'job_complete') {
fetchJobs();
fetchStats();
}
};
websocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
setWs(websocket);
};
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 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 startTranscription = async () => {
if (!newUrl()) return;
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'
}),
});
if (response.ok) {
setNewUrl('');
fetchJobs();
fetchStats();
}
} catch (error) {
console.error('Error starting transcription:', error);
}
setIsLoading(false);
};
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';
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'pending': return '⏳';
case 'downloading': return '⬇️';
case 'transcribing': return '🎙️';
case 'completed': return '✅';
case 'failed': return '❌';
default: return '❓';
}
};
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>
{/* 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>
);
}

View file

@ -0,0 +1,256 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
interface Playlist {
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);
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 createPlaylist = async () => {
if (!newPlaylistName() || !newUrls()) return;
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
}),
});
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 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] || '📁';
};
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>
{/* 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>
{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>
);
}

View file

@ -0,0 +1,245 @@
import { createSignal, onMount, For } from 'solid-js';
interface Model {
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);
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 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 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';
}
};
return (
<div class="max-w-4xl mx-auto space-y-6">
<h1 class="text-2xl font-bold mb-6">Einstellungen</h1>
{/* 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>
{/* 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>
{/* 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>
<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>
{/* 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>
);
}

View file

@ -0,0 +1,238 @@
import { createSignal, createEffect, onMount, For, Show } from 'solid-js';
interface Transcript {
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);
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());
}
});
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 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 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 '📁';
};
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>
{/* 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>
<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={!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>
);
}

View file

@ -0,0 +1,109 @@
---
export interface Quote {
text: string;
talk: string;
context?: string;
timestamp?: string;
}
export interface Props {
quotes: Quote[];
speakerName: string;
}
const { quotes, speakerName } = Astro.props;
---
<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">
Wichtige Zitate & Einsichten
</h2>
<div class="grid md:grid-cols-2 gap-6">
{quotes.map((quote, index) => (
<div
class="bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group"
>
<!-- Quote Icon -->
<div class="flex items-start gap-4">
<div class="flex-shrink-0">
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-8 w-8 text-theme-primary/30 group-hover:text-theme-primary/50 transition-colors"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z"/>
</svg>
</div>
<div class="flex-1">
<!-- Quote Text -->
<blockquote class="text-theme-text text-lg leading-relaxed mb-4">
"{quote.text}"
</blockquote>
<!-- Context if available -->
{quote.context && (
<p class="text-theme-text-muted text-sm mb-3 italic">
Kontext: {quote.context}
</p>
)}
<!-- Source -->
<div class="flex items-center justify-between">
<cite class="text-theme-primary text-sm not-italic font-medium">
— {quote.talk}
</cite>
{quote.timestamp && (
<span class="text-theme-text-muted text-xs">
{quote.timestamp}
</span>
)}
</div>
</div>
</div>
</div>
))}
</div>
<!-- Load More Button -->
{quotes.length > 6 && (
<div class="text-center mt-8">
<button
id="loadMoreQuotes"
class="bg-theme-primary/10 text-theme-primary px-6 py-3 rounded-lg hover:bg-theme-primary/20 transition-colors"
>
Mehr Zitate laden
</button>
</div>
)}
</div>
</section>
<script>
// Initially hide quotes beyond the first 6
const quoteCards = document.querySelectorAll('.grid > div');
quoteCards.forEach((card, index) => {
if (index >= 6) {
(card as HTMLElement).style.display = 'none';
}
});
// Load more functionality
const loadMoreBtn = document.getElementById('loadMoreQuotes');
let visibleQuotes = 6;
loadMoreBtn?.addEventListener('click', () => {
const hiddenQuotes = Array.from(quoteCards).slice(visibleQuotes, visibleQuotes + 4);
hiddenQuotes.forEach(card => {
(card as HTMLElement).style.display = 'block';
});
visibleQuotes += 4;
if (visibleQuotes >= quoteCards.length) {
loadMoreBtn.style.display = 'none';
}
});
</script>

View file

@ -0,0 +1,105 @@
---
export interface Props {
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>

View file

@ -0,0 +1,100 @@
---
export interface Props {
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>

View file

@ -0,0 +1,128 @@
---
export interface Talk {
id: string;
title: string;
date: string;
duration: string;
thumbnail?: string;
description: string;
tags: string[];
url: string;
views?: string;
}
export interface Props {
talks: Talk[];
showFilters?: boolean;
}
const { talks, showFilters = true } = Astro.props;
---
<section class="py-12">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-8">
<h2 class="text-3xl font-bold text-theme-text mb-4 md:mb-0">
Alle Vorträge
</h2>
{showFilters && (
<div class="flex gap-4">
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Neueste zuerst</option>
<option>Älteste zuerst</option>
<option>Beliebteste</option>
<option>Längste Dauer</option>
</select>
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Alle Themen</option>
<option>Behavioral Economics</option>
<option>Marketing</option>
<option>Psychology</option>
<option>Innovation</option>
</select>
</div>
)}
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{talks.map(talk => (
<article class="bg-theme-card rounded-xl overflow-hidden border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 group">
<!-- Thumbnail -->
{talk.thumbnail ? (
<div class="relative aspect-video overflow-hidden bg-theme-background">
<img
src={talk.thumbnail}
alt={talk.title}
class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
{talk.duration}
</div>
</div>
) : (
<div class="relative aspect-video bg-gradient-to-br from-theme-primary/20 to-theme-secondary/20 flex items-center justify-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-16 w-16 text-theme-primary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-2 py-1 rounded">
{talk.duration}
</div>
</div>
)}
<!-- Content -->
<div class="p-6">
<h3 class="text-xl font-semibold text-theme-text mb-2 line-clamp-2 group-hover:text-theme-primary transition-colors">
<a href={talk.url} class="hover:underline">
{talk.title}
</a>
</h3>
<p class="text-theme-text-muted text-sm mb-4 line-clamp-3">
{talk.description}
</p>
<!-- Meta Info -->
<div class="flex items-center justify-between text-xs text-theme-text-muted mb-4">
<span>{talk.date}</span>
{talk.views && <span>{talk.views} Aufrufe</span>}
</div>
<!-- Tags -->
<div class="flex flex-wrap gap-2">
{talk.tags.slice(0, 3).map(tag => (
<span class="inline-block bg-theme-primary/10 text-theme-primary text-xs px-2 py-1 rounded-full">
{tag}
</span>
))}
{talk.tags.length > 3 && (
<span class="inline-block bg-theme-background text-theme-text-muted text-xs px-2 py-1 rounded-full">
+{talk.tags.length - 3}
</span>
)}
</div>
</div>
</article>
))}
</div>
</div>
</section>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View file

@ -0,0 +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(),
}),
});
export const collections = { talks };

View file

@ -0,0 +1,188 @@
---
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"
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."
---
## Executive Summary
In this thought-provoking TED talk, advertising legend Rory Sutherland demonstrates that our happiness and satisfaction depend far more on how we perceive things than on objective reality itself. Through witty examples ranging from electronic cigarettes to train journeys, he argues that psychology and perception deserve equal consideration alongside engineering and economics when solving problems. The core message: **changing the frame changes everything**.
## 🎯 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
- **"The circumstances of our lives may actually matter less to our happiness than the sense of control we feel over our lives."**
- **"What you call things affects how you react to them, viscerally and morally."**
- **"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)*
## 📚 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
1. **Before seeking expensive technical solutions, consider psychological reframing** - it's often cheaper and more effective
2. **Focus on perceived control** - Give people choices and milestones (like the blue pills example for antibiotics)
3. **Test psychological interventions carefully** - What works in one context may backfire in another (red vs. green traffic lights)
4. **Consider the frame when communicating** - How you label something determines how people respond ("bailout of Greece" vs. "bailout of banks")
5. **Don't underestimate perceived value** - Customer perception is reality; improving perception is as valid as improving the product
## 🔗 Related Ideas
- **Behavioral Economics**: Daniel Kahneman's work on cognitive biases
- **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
## 💭 Reflection Questions
1. What aspects of your life could be improved through reframing rather than material change?
2. Where might your organization be over-investing in technical solutions while ignoring psychological ones?
3. How can you apply the "control principle" to improve satisfaction in your work or relationships?
---
*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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.

View file

@ -0,0 +1,222 @@
---
title: "Leaders Eat Last: Why Some Teams Pull Together and Others Don't"
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"
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."
---
## 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.
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
**Der Unterschied:** Die äußeren Gefahren sind konstant und unkontrollierbar. **Die einzige Variable sind die Bedingungen innerhalb der Organisation.**
## 🎯 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.
## 🛡️ 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.
**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.
**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:
**Statt:** "Sir, Erlaubnis zu tauchen auf 400 Fuß" → "Erlaubnis erteilt"
**Neu:** "Ich beabsichtige zu tauchen auf 400 Fuß"
**Der Unterschied:** Alle Verantwortung liegt jetzt bei der Person, die die Handlung ausführt.
**Das Ergebnis:** Die Santa Fe wurde zur **bestbewerteten Crew in der Marinegeschichte** - gleiche Leute, gleiche Ausrüstung, nur andere Führung.
## 🔑 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
- Damit das Kind mehr erreichen kann, als sie selbst
**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)
## 🦅 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.
**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.
## 🔄 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.
## 💡 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
**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?**
- 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
- Konsistenz schlägt Intensität - kleine, regelmäßige Handlungen sind wichtiger als große Gesten
## 🌟 Warum dieser Talk revolutionär ist
Dieser Microsoft Research Talk ist vermutlich Sineks tiefgreifendster Vortrag über Leadership. Während seine anderen Talks oft inspirierend und motivational sind, ist dieser wissenschaftlich fundiert und praktisch umsetzbar.
Er zeigt, dass Leadership keine mystische Eigenschaft ist, sondern eine erlernbare Fähigkeit, die auf universellen menschlichen Bedürfnissen basiert. Die Kombination aus evolutionärer Psychologie, praktischen Beispielen (Marines, David Marquet) und konkreten Handlungsanweisungen macht diesen Talk zu einem Masterclass in authentischer Führung.
**Die zentrale Botschaft:** Du musst nicht warten, bis jemand dich zum Leader ernennt. Du kannst heute anfangen, für die Menschen links und rechts von dir zu sorgen. Das ist Leadership.
---
## Vollständiges Transkript
*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]

View file

@ -0,0 +1,203 @@
---
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"
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."
---
## Executive Summary
In diesem längeren, nachdenklichen Vortrag bei Creative Mornings geht Simon Sinek tief in die Psychologie der Arbeitsunzufriedenheit ein. Er beginnt mit einem schockierenden Vergleich: **250.000 Menschen sterben jährlich in US-Krankenhäusern an vermeidbaren Fehlern - nicht wegen schlechter Ärzte oder Technologie, sondern weil das Personal nicht füreinander sorgt.** Diese Metapher zeigt das fundamentale Problem unserer Arbeitswelt auf: Wenn Menschen nicht erfüllt sind und nicht füreinander sorgen, entstehen katastrophale Folgen.
Seine zentrale These: **Erfüllung kommt davon, Zeit und Energie für andere zu investieren** - nicht von Geld, Status oder äußeren Belohnungen.
## 🎯 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
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.
## 💡 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.
## 🧠 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.
## 🔥 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)
- Ihnen Vertrauen in ihre Fähigkeiten geben
## 🌱 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
- Halte den Aufzug auf
- Stelle dein Handy weg während Meetings
> "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.
## ⚠️ 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..."
**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.
## 🏢 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.
## 🎯 Die große Vision
> "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.**
---
## Warum dieser Talk besonders wichtig ist
"Love Your Work" ist einer von Sineks persönlichsten und tiefgreifendsten Vorträgen. Während seine anderen Talks oft auf konkrete Business-Strategien fokussieren, geht dieser zur emotionalen Wurzel unserer Arbeitsunzufriedenheit.
Er zeigt schonungslos auf, wie die Art, wie wir arbeiten, nicht nur uns selbst zerstört, sondern auch unsere Familien und die gesamte Gesellschaft. Gleichzeitig bietet er praktische, umsetzbare Lösungen, die jeder einzelne sofort anwenden kann.
Der Talk ist ein Aufruf zur persönlichen Verantwortung: Du musst nicht warten, bis dein Boss oder dein Unternehmen sich ändert. Du kannst heute anfangen, kleine Akte der Güte zu vollbringen, und dadurch eine Welle positiver Veränderung auslösen.
---
## Vollständiges Transkript
*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]

View file

@ -0,0 +1,214 @@
---
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"
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."
---
## Executive Summary
In diesem viral gegangenen Interview (über 100 Millionen Aufrufe) analysiert Simon Sinek schonungslos die Herausforderungen der Millennial-Generation am Arbeitsplatz. Er identifiziert vier Hauptfaktoren, die zu den Problemen geführt haben: **gescheiterte Erziehungsstrategien, Technologie-Sucht, ein Verlangen nach sofortiger Befriedigung und corporate Umgebungen, die sich mehr für Zahlen als für Menschen interessieren.** Seine zentrale Botschaft: Es ist nicht die Schuld der Millennials - sie wurden schlecht behandelt, und Unternehmen müssen jetzt die Verantwortung übernehmen.
## 🎯 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
- Schutz vor allen Herausforderungen und Rückschlägen
**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:**
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
- Keine Übung in Geduld und langfristigen Zielen
**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
- Keine Balance in der digitalen Welt lehren
- Die sofortige Befriedigung verstärken statt Geduld zu lehren
## 💡 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.
## 🔥 Praktische Lösungen
### Für Unternehmen:
**1. Handyfreie Konferenzräume**
> "There should be no cell phones in conference rooms, none, zero."
- Kein "Handy auf stumm" - komplett weg
- In den Warteminuten vor Meetings entstehen echte Beziehungen
- "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
- Konsistenz über Zeit zeigen
- 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
- Selbstvertrauen aufbauen helfen
- Extra Anstrengungen unternehmen, um diese Generation zu unterstützen
### 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
## 🧠 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.
## 🌟 Warum dieses Interview wichtig ist
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
- **Führungskräfte:** Umgebungen zu schaffen, die echte menschliche Entwicklung fördern
Das Interview ist gleichzeitig eine Diagnose und ein Aufruf zum Handeln - und zeigt, dass Veränderung möglich ist, wenn wir die richtigen Systeme und Umgebungen schaffen.
---
## Vollständiges Transkript
*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 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?
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?
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.
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?
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?
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?
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.
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 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?
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 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.

View file

@ -0,0 +1,148 @@
---
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"
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."
---
## Executive Summary
In diesem beeindruckenden TED Talk zeigt Simon Sinek anhand der Geschichte von Captain William Swenson auf, was echte Führung ausmacht. Durch die Metapher des "Circle of Safety" erklärt er, warum große Führungskräfte ihre eigenen Interessen zurückstellen, um ihre Teams zu schützen und zu stärken. Die zentrale Botschaft: **Leadership ist die bewusste Entscheidung, anderen zu dienen, nicht sich selbst.**
## 🎯 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.
## 💡 Memorwürdige Zitate
- **"Leadership is not about being in charge. Leadership is about taking care of those in your charge."**
- **"The cost of leadership is self-interest."**
- **"Great leaders would never sacrifice the people to save the numbers. They would sooner sacrifice the numbers to save the people."**
- **"When leaders make 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."**
- **"We call them leaders because they go first. We call them leaders because they take the risk before anybody else does."**
## 📚 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
## 🧠 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
In einer Zeit, in der Vertrauen in Führungskräfte und Institutionen schwindet, bietet Sinek einen klaren Weg zurück zu echter Leadership. Seine Botschaft ist zeitlos und universell anwendbar - von kleinen Teams bis zu ganzen Nationen.
Der Vortrag zeigt, dass Leadership nichts mit Macht oder Kontrolle zu tun hat, sondern mit Dienstbereitschaft und dem Mut, Verantwortung zu übernehmen. Es ist ein Aufruf an alle, die in Führungspositionen sind oder danach streben, ihre Rolle neu zu überdenken.
---
## Vollständiges Transkript
*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.
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?
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.
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.
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 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.
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.
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.
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.
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.
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?
Thank you very much. Thank you. Thank you. Thank you.

View file

@ -0,0 +1 @@
/// <reference path="../.astro/types.d.ts" />

View file

@ -0,0 +1,48 @@
---
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!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>
</html>
<style is:global>
@import '../styles/global.css';
</style>

View file

@ -0,0 +1,789 @@
---
import { getCollection } from 'astro:content';
import '../styles/themes.css';
export interface Props {
title?: string;
description?: string;
}
const { title = 'YouTube Wisdom Library', description = 'A curated collection of insights from the best talks' } = Astro.props;
// Get all talks for the sidebar
const talks = await getCollection('talks');
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();
});
// 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>);
---
<!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>{title}</title>
<meta name="description" content={description}>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: system-ui, -apple-system, sans-serif;
}
.app-layout {
display: flex;
height: 100vh;
background: rgb(var(--theme-background));
}
/* Desktop Sidebar */
.nav-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;
z-index: 100;
}
.nav-header {
padding: 1.5rem;
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
}
.logo {
font-size: 1.3rem;
font-weight: 700;
color: rgb(var(--theme-primary));
margin: 0 0 1.5rem 0;
display: flex;
align-items: center;
gap: 0.5rem;
text-decoration: none;
}
.nav-links {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.nav-link {
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-link:hover {
background: rgba(var(--theme-primary), 0.08);
color: rgb(var(--theme-primary));
}
.nav-link.active {
background: rgba(var(--theme-primary), 0.1);
color: rgb(var(--theme-primary));
}
.nav-icon {
font-size: 1.1rem;
width: 24px;
text-align: center;
}
.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 h2 {
font-size: 0.75rem;
font-weight: 600;
color: rgb(var(--theme-text-muted));
letter-spacing: 0.05em;
margin: 0;
}
.talks-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.talks-list::-webkit-scrollbar {
width: 6px;
}
.talks-list::-webkit-scrollbar-track {
background: transparent;
}
.talks-list::-webkit-scrollbar-thumb {
background: rgba(var(--theme-primary), 0.2);
border-radius: 3px;
}
.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;
}
.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: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-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-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));
}
/* Sidebar Theme Section */
.sidebar-theme-section {
border-top: 1px solid rgba(var(--theme-primary), 0.1);
padding: 1rem 1.5rem 1.5rem;
}
.theme-header {
margin-bottom: 1rem;
}
.theme-header h3 {
font-size: 0.75rem;
font-weight: 600;
color: rgb(var(--theme-text-muted));
letter-spacing: 0.05em;
margin: 0;
}
.theme-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.theme-selector {
position: relative;
}
.theme-button {
width: 100%;
padding: 0.75rem 1rem;
background: rgba(var(--theme-background), 0.5);
border: 1px solid rgba(var(--theme-primary), 0.1);
border-radius: 0.5rem;
color: rgb(var(--theme-text));
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
font-size: 0.9rem;
transition: all 0.2s ease;
}
.theme-button:hover {
background: rgba(var(--theme-primary), 0.05);
border-color: rgba(var(--theme-primary), 0.2);
}
.theme-icon {
font-size: 1.1rem;
}
.theme-name {
flex: 1;
text-align: left;
margin-left: 0.75rem;
}
.theme-arrow {
width: 16px;
height: 16px;
transition: transform 0.2s ease;
}
.theme-menu {
position: absolute;
bottom: 100%;
left: 0;
right: 0;
margin-bottom: 0.5rem;
background: rgb(var(--theme-card));
border: 1px solid rgba(var(--theme-primary), 0.1);
border-radius: 0.5rem;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
opacity: 0;
transform: scale(0.95) translateY(10px);
transition: all 0.2s ease;
z-index: 50;
}
.theme-menu:not(.hidden) {
opacity: 1;
transform: scale(1) translateY(0);
}
.theme-option {
width: 100%;
padding: 0.75rem 1rem;
background: none;
border: none;
text-align: left;
color: rgb(var(--theme-text));
cursor: pointer;
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.9rem;
transition: all 0.2s ease;
border-radius: 0.25rem;
margin: 0.25rem;
}
.theme-option:hover {
background: rgba(var(--theme-primary), 0.08);
}
.theme-option.active {
background: rgba(var(--theme-primary), 0.1);
color: rgb(var(--theme-primary));
}
.dark-toggle {
padding: 0.75rem;
background: rgba(var(--theme-background), 0.5);
border: 1px solid rgba(var(--theme-primary), 0.1);
border-radius: 0.5rem;
color: rgb(var(--theme-text));
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.dark-toggle:hover {
background: rgba(var(--theme-primary), 0.05);
border-color: rgba(var(--theme-primary), 0.2);
}
.theme-toggle-icon {
width: 20px;
height: 20px;
}
/* Main Content Area */
.main-content {
flex: 1;
margin-left: 320px;
overflow-y: auto;
position: relative;
}
/* Mobile Navigation */
.mobile-nav {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
height: 60px;
background: rgb(var(--theme-card));
border-bottom: 1px solid rgba(var(--theme-primary), 0.1);
z-index: 99;
padding: 0 1rem;
align-items: center;
justify-content: space-between;
}
.mobile-menu-toggle {
background: none;
border: none;
color: rgb(var(--theme-text));
cursor: pointer;
padding: 0.5rem;
}
.mobile-logo {
font-size: 1.1rem;
font-weight: 700;
color: rgb(var(--theme-primary));
text-decoration: none;
}
.mobile-nav-links {
display: flex;
gap: 1rem;
}
.mobile-nav-link {
color: rgb(var(--theme-text-muted));
text-decoration: none;
font-size: 0.9rem;
}
/* Responsive Design */
@media (max-width: 1024px) {
.nav-sidebar {
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.nav-sidebar.open {
transform: translateX(0);
}
.main-content {
margin-left: 0;
padding-top: 60px;
}
.mobile-nav {
display: flex;
}
/* Overlay for mobile sidebar */
.sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 98;
}
.sidebar-overlay.active {
display: block;
}
}
@media (max-width: 768px) {
.nav-sidebar {
width: 280px;
}
.mobile-nav-links {
display: none;
}
}
</style>
</head>
<body class="bg-theme-background text-theme-text">
<div class="app-layout">
<!-- Desktop Sidebar / Mobile Drawer -->
<nav class="nav-sidebar" id="sidebar">
<div class="nav-header">
<a href="/" class="logo">
📚 Wisdom Library
</a>
<div class="nav-links">
<a href="/" class={`nav-link ${currentPath === '/' ? 'active' : ''}`}>
<span class="nav-icon">🏠</span>
<span>Home</span>
</a>
<a href="/speakers" class={`nav-link ${currentPath.includes('/speakers') ? 'active' : ''}`}>
<span class="nav-icon">🎤</span>
<span>Speakers</span>
</a>
<a href="/admin" class={`nav-link ${currentPath.includes('/admin') ? 'active' : ''}`}>
<span class="nav-icon">⚙️</span>
<span>Admin</span>
</a>
</div>
</div>
<div class="talks-section">
<div class="section-header">
<h2>RECENT TALKS</h2>
</div>
<div class="talks-list">
{Object.entries(talksBySpeaker).map(([speaker, speakerTalks]) => (
<div class="speaker-group">
<div class="speaker-header">
<span>{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>
</a>
);
})}
</div>
))}
</div>
</div>
<!-- Theme Settings in Sidebar -->
<div class="sidebar-theme-section">
<div class="theme-header">
<h3>APPEARANCE</h3>
</div>
<div class="theme-controls">
<!-- Theme Selector -->
<div class="theme-selector">
<button id="theme-menu-button" class="theme-button">
<span id="theme-icon" class="theme-icon">🌊</span>
<span id="theme-name" class="theme-name">Ocean</span>
<svg class="theme-arrow" 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="theme-menu hidden">
<button data-theme="ocean" class="theme-option">
<span>🌊</span> Ocean
</button>
<button data-theme="forest" class="theme-option">
<span>🌲</span> Forest
</button>
<button data-theme="sunset" class="theme-option">
<span>🌅</span> Sunset
</button>
<button data-theme="monochrome" class="theme-option">
<span>⚫</span> Monochrome
</button>
</div>
</div>
<!-- Dark Mode Toggle -->
<button id="dark-toggle" class="dark-toggle">
<svg id="sun-icon" class="theme-toggle-icon 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="theme-toggle-icon" 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>
</div>
</nav>
<!-- Mobile Navigation Bar -->
<nav class="mobile-nav">
<button class="mobile-menu-toggle" id="mobileMenuToggle">
<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>
<a href="/" class="mobile-logo">📚 Wisdom Library</a>
<div class="mobile-nav-links">
<a href="/" class="mobile-nav-link">Home</a>
<a href="/speakers" class="mobile-nav-link">Speakers</a>
</div>
</nav>
<!-- Overlay for mobile -->
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<!-- Main Content -->
<main class="main-content">
<slot />
</main>
</div>
<script>
// Theme Management
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();
}
init() {
this.loadPreferences();
this.applyTheme();
this.setupEventListeners();
this.watchSystemPreference();
}
loadPreferences() {
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 {
this.isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
}
}
savePreferences() {
localStorage.setItem('theme', this.currentTheme);
localStorage.setItem('darkMode', this.isDark.toString());
}
applyTheme() {
const html = document.documentElement;
html.setAttribute('data-theme', this.currentTheme);
if (this.isDark) {
html.classList.add('dark');
} else {
html.classList.remove('dark');
}
this.updateUI();
}
updateUI() {
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;
}
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');
}
}
document.querySelectorAll('.theme-option').forEach(btn => {
const theme = btn.getAttribute('data-theme');
if (theme === this.currentTheme) {
btn.classList.add('active');
} else {
btn.classList.remove('active');
}
});
}
setupEventListeners() {
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');
} else {
menu.classList.add('hidden');
}
});
document.addEventListener('click', (e) => {
if (!menuButton.contains(e.target) && !menu.contains(e.target)) {
menu.classList.add('hidden');
}
});
}
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();
const menu = document.getElementById('theme-menu');
if (menu) {
menu.classList.add('hidden');
}
}
});
});
const darkToggle = document.getElementById('dark-toggle');
if (darkToggle) {
darkToggle.addEventListener('click', () => {
this.isDark = !this.isDark;
this.applyTheme();
this.savePreferences();
});
}
}
watchSystemPreference() {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', (e) => {
if (localStorage.getItem('darkMode') === null) {
this.isDark = e.matches;
this.applyTheme();
}
});
}
}
document.addEventListener('DOMContentLoaded', () => {
// Initialize Theme Manager
new ThemeManager();
// Sidebar functionality
const sidebar = document.getElementById('sidebar');
const mobileMenuToggle = document.getElementById('mobileMenuToggle');
const sidebarOverlay = document.getElementById('sidebarOverlay');
// Mobile menu toggle
if (mobileMenuToggle) {
mobileMenuToggle.addEventListener('click', () => {
sidebar?.classList.toggle('open');
sidebarOverlay?.classList.toggle('active');
});
}
// Close sidebar when clicking overlay
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', () => {
sidebar?.classList.remove('open');
sidebarOverlay.classList.remove('active');
});
}
// Close sidebar on mobile when clicking a link
const talkCards = sidebar?.querySelectorAll('.talk-card');
talkCards?.forEach(card => {
card.addEventListener('click', () => {
if (window.innerWidth <= 1024) {
sidebar?.classList.remove('open');
sidebarOverlay?.classList.remove('active');
}
});
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,108 @@
---
export interface Props {
title: string;
}
const { title } = Astro.props;
---
<!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>&copy; 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>

View file

@ -0,0 +1,179 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
const currentPath = Astro.url.pathname;
---
<!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>

View file

@ -0,0 +1,35 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
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>
<!-- 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>

View file

@ -0,0 +1,289 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import '../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Sample speakers data - would come from API/database
const speakers = [
{
id: "rory-sutherland",
name: "Rory Sutherland",
title: "Vice Chairman, Ogilvy UK",
bio: "Behavioral Economics & Marketing Psychology Expert",
talkCount: 12,
totalViews: "15M+",
topics: ["Behavioral Economics", "Marketing", "Psychology"],
imageUrl: null,
featured: true
},
{
id: "simon-sinek",
name: "Simon Sinek",
title: "Leadership Expert & Author",
bio: "Leadership Expert und Autor von 'Start With Why' - mit über 200M+ Views",
talkCount: 4,
totalViews: "200M+",
topics: ["Leadership", "Purpose", "Trust", "Team Building"],
imageUrl: null,
featured: true
},
{
id: "brene-brown",
name: "Brené Brown",
title: "Research Professor",
bio: "Vulnerability, Courage, and Leadership Researcher",
talkCount: 5,
totalViews: "35M+",
topics: ["Vulnerability", "Leadership", "Courage"],
imageUrl: null,
featured: true
},
{
id: "daniel-kahneman",
name: "Daniel Kahneman",
title: "Nobel Laureate Psychologist",
bio: "Autor von 'Thinking, Fast and Slow'",
talkCount: 6,
totalViews: "8M+",
topics: ["Psychology", "Decision Making", "Economics"],
imageUrl: null
},
{
id: "yuval-noah-harari",
name: "Yuval Noah Harari",
title: "Historian & Author",
bio: "Autor von 'Sapiens' und 'Homo Deus'",
talkCount: 9,
totalViews: "20M+",
topics: ["History", "Future", "Technology"],
imageUrl: null
},
{
id: "malcolm-gladwell",
name: "Malcolm Gladwell",
title: "Author & Journalist",
bio: "Bestselling Author und New Yorker Staff Writer",
talkCount: 7,
totalViews: "12M+",
topics: ["Social Science", "Psychology", "Success"],
imageUrl: null
}
];
const featuredSpeakers = speakers.filter(s => s.featured);
const allSpeakers = speakers.sort((a, b) => a.name.localeCompare(b.name));
---
<!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>Sprecher | YouTube Wisdom Library</title>
<meta name="description" content="Entdecken Sie führende Denker und ihre inspirierenden Vorträge in unserer kuratierten Sammlung.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Hero Section -->
<section class="bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Führende Denker & Sprecher
</h1>
<p class="text-xl text-theme-text-muted max-w-3xl mx-auto">
Entdecken Sie die brillantesten Köpfe unserer Zeit und ihre revolutionären Ideen,
die die Art und Weise verändern, wie wir denken und handeln.
</p>
</div>
</section>
<!-- Featured Speakers -->
<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">
Featured Speakers
</h2>
<div class="grid md:grid-cols-3 gap-8">
{featuredSpeakers.map(speaker => (
<a
href={`/speakers/${speaker.id}`}
class="group bg-theme-card rounded-xl p-6 border border-theme-border/20 hover:border-theme-primary/30 transition-all duration-300 hover:shadow-xl"
>
<div class="flex items-start gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{speaker.imageUrl ? (
<img
src={speaker.imageUrl}
alt={speaker.name}
class="w-20 h-20 rounded-full object-cover"
/>
) : (
<div class="w-20 h-20 rounded-full bg-gradient-to-br from-theme-primary to-theme-secondary flex items-center justify-center">
<span class="text-2xl text-white font-bold">
{speaker.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
)}
</div>
<!-- Info -->
<div class="flex-1">
<h3 class="text-xl font-semibold text-theme-text group-hover:text-theme-primary transition-colors mb-1">
{speaker.name}
</h3>
<p class="text-sm text-theme-primary mb-2">
{speaker.title}
</p>
<p class="text-theme-text-muted text-sm mb-3">
{speaker.bio}
</p>
<!-- Stats -->
<div class="flex items-center gap-4 text-xs text-theme-text-muted mb-3">
<span class="flex items-center gap-1">
<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="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>
{speaker.talkCount} Talks
</span>
<span class="flex items-center gap-1">
<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="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>
{speaker.totalViews}
</span>
</div>
<!-- Topics -->
<div class="flex flex-wrap gap-1">
{speaker.topics.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>
</a>
))}
</div>
</div>
</section>
<!-- All Speakers Grid -->
<section class="py-12 bg-theme-card/30">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between mb-8">
<h2 class="text-3xl font-bold text-theme-text">
Alle Sprecher
</h2>
<!-- Search/Filter -->
<div class="flex gap-4">
<input
type="text"
placeholder="Sprecher suchen..."
class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2 w-64"
/>
<select class="bg-theme-card text-theme-text border border-theme-border/30 rounded-lg px-4 py-2">
<option>Alle Themen</option>
<option>Psychology</option>
<option>Leadership</option>
<option>Technology</option>
<option>Economics</option>
<option>Innovation</option>
</select>
</div>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
{allSpeakers.map(speaker => (
<a
href={`/speakers/${speaker.id}`}
class="group 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-center gap-4">
<!-- Avatar -->
<div class="flex-shrink-0">
{speaker.imageUrl ? (
<img
src={speaker.imageUrl}
alt={speaker.name}
class="w-16 h-16 rounded-full object-cover"
/>
) : (
<div class="w-16 h-16 rounded-full bg-gradient-to-br from-theme-primary/50 to-theme-secondary/50 flex items-center justify-center">
<span class="text-xl text-white font-bold">
{speaker.name.split(' ').map(n => n[0]).join('')}
</span>
</div>
)}
</div>
<!-- Info -->
<div class="flex-1">
<h3 class="text-lg font-semibold text-theme-text group-hover:text-theme-primary transition-colors">
{speaker.name}
</h3>
<p class="text-sm text-theme-text-muted mb-2">
{speaker.bio}
</p>
<div class="flex items-center gap-3 text-xs text-theme-text-muted">
<span>{speaker.talkCount} Talks</span>
<span>•</span>
<span>{speaker.totalViews} Views</span>
</div>
</div>
</div>
</a>
))}
</div>
</div>
</section>
<!-- CTA Section -->
<section class="py-16">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h2 class="text-3xl font-bold text-theme-text mb-4">
Sprecher vorschlagen
</h2>
<p class="text-theme-text-muted mb-8">
Vermissen Sie einen wichtigen Denker in unserer Sammlung?
Lassen Sie es uns wissen!
</p>
<button class="bg-theme-primary text-white px-8 py-3 rounded-lg hover:opacity-90 transition-opacity">
Sprecher vorschlagen
</button>
</div>
</section>
<Footer />
<script>
// Simple search functionality
const searchInput = document.querySelector('input[type="text"]');
const speakerCards = document.querySelectorAll('.grid a');
searchInput?.addEventListener('input', (e) => {
const searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
speakerCards.forEach(card => {
const text = card.textContent?.toLowerCase() || '';
if (text.includes(searchTerm)) {
(card as HTMLElement).style.display = 'block';
} else {
(card as HTMLElement).style.display = 'none';
}
});
});
</script>
</body>
</html>

View file

@ -0,0 +1,391 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import SpeakerHero from '../../components/speakers/SpeakerHero.astro';
import SpeakerStats from '../../components/speakers/SpeakerStats.astro';
import QuoteCollection from '../../components/speakers/QuoteCollection.astro';
import TalkGrid from '../../components/speakers/TalkGrid.astro';
import '../../styles/themes.css';
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"
};
const statsData = {
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"
}
];
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"
}
];
// 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: "💎"
}
];
---
<!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>
<!-- 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>

View file

@ -0,0 +1,658 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Process talks data combining transcript and analysis
const combinedData = speakerTalks.map(talk => {
const content = talk.compiledContent();
// Split content at transcript marker
const transcriptMarker = '## 📜 Full Transcript';
const [analysisContent, transcriptContent] = content.split(transcriptMarker);
return {
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
summary: talk.frontmatter.summary,
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0,
analysisContent: analysisContent.trim(),
transcriptContent: transcriptContent ? transcriptContent.trim() : '',
fullContent: content
};
});
// Get unique filter options
const categories = [...new Set(combinedData.map(talk => talk.category))];
const years = [...new Set(combinedData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(combinedData.map(talk => talk.venue))];
const allTags = [...new Set(combinedData.flatMap(talk => talk.tags))];
---
<!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>{speakerData.name} - Komplette Sammlung | YouTube Wisdom Library</title>
<meta name="description" content="Alle Inhalte von {speakerData.name} auf einer Seite - Transkripte, Analysen und Insights kombiniert.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-accent/10 to-theme-primary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Komplette {speakerData.name} Sammlung
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{combinedData.length} Vorträge • Analysen + Transkripte kombiniert
</p>
<!-- Content Type Toggle -->
<div class="flex flex-wrap justify-center gap-2 mb-8">
<button class="content-toggle active bg-theme-accent text-white px-6 py-3 rounded-lg transition-all font-semibold" data-content="both">
🔄 Analyse + Transkript
</button>
<button class="content-toggle bg-theme-card border border-theme-border/30 px-6 py-3 rounded-lg hover:bg-theme-primary/10 transition-all font-semibold" data-content="analysis">
📊 Nur Analysen
</button>
<button class="content-toggle bg-theme-card border border-theme-border/30 px-6 py-3 rounded-lg hover:bg-theme-primary/10 transition-all font-semibold" data-content="transcript">
📜 Nur Transkripte
</button>
</div>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllContent"
class="bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alles kopieren
</button>
<button
id="copyFilteredContent"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefiltert kopieren
</button>
<div class="text-sm text-theme-text-muted">
Aktuelle Auswahl: <span id="currentSelection">Analyse + Transkript</span>
• <span id="totalWords">{combinedData.reduce((sum, talk) => sum + talk.readingTime * 250, 0).toLocaleString()}</span> Wörter geschätzt
</div>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Inhalten..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="long">
📖 Lange Vorträge (>15 Min)
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="psychology">
🧠 Psychology Tags
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{combinedData.length}</span> von {combinedData.length} Vorträgen angezeigt
• <span id="filteredWords">-</span> Wörter geschätzt
</div>
</div>
</div>
</div>
</div>
<!-- Combined Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="contentContainer" class="space-y-12">
{combinedData.map(item => (
<article
class="content-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={item.title.toLowerCase()}
data-year={item.year}
data-category={item.category}
data-venue={item.venue}
data-tags={JSON.stringify(item.tags)}
data-reading-time={item.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-8">
<div class="flex justify-between items-start mb-4">
<h2 class="text-3xl font-bold text-theme-text">
{item.title}
</h2>
<button
class="copy-single-content bg-theme-accent/10 text-theme-accent px-4 py-2 rounded-lg hover:bg-theme-accent/20 transition-all flex items-center gap-2 text-sm font-medium"
data-content-id={item.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted mb-4">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(item.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {item.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {item.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {item.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
{item.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
<!-- Summary Preview -->
<div class="bg-theme-background/50 rounded-lg p-4">
<p class="text-theme-text-muted italic">
{item.summary}
</p>
</div>
</div>
<!-- Content Sections -->
<div class="content-sections">
<!-- Both Analysis and Transcript (default) -->
<div class="content-type both-content">
<div class="grid lg:grid-cols-2 gap-8">
<!-- Analysis Column -->
<div class="analysis-column">
<div class="sticky top-32">
<div class="bg-theme-secondary/5 rounded-lg p-6 border border-theme-secondary/20">
<h3 class="text-xl font-bold text-theme-text mb-4 flex items-center gap-2">
<span>📊</span> Analyse & Insights
</h3>
<div class="prose prose-sm max-w-none text-theme-text" set:html={item.analysisContent} />
</div>
</div>
</div>
<!-- Transcript Column -->
<div class="transcript-column">
<div class="bg-theme-primary/5 rounded-lg p-6 border border-theme-primary/20">
<h3 class="text-xl font-bold text-theme-text mb-4 flex items-center gap-2">
<span>📜</span> Vollständiges Transkript
</h3>
<div class="prose prose-sm max-w-none text-theme-text" set:html={item.transcriptContent} />
</div>
</div>
</div>
</div>
<!-- Analysis Only -->
<div class="content-type analysis-only" style="display: none;">
<div class="bg-theme-secondary/5 rounded-lg p-6 border border-theme-secondary/20">
<h3 class="text-xl font-bold text-theme-text mb-6 flex items-center gap-2">
<span>📊</span> Analyse & Insights
</h3>
<div class="prose prose-lg max-w-none text-theme-text" set:html={item.analysisContent} />
</div>
</div>
<!-- Transcript Only -->
<div class="content-type transcript-only" style="display: none;">
<div class="bg-theme-primary/5 rounded-lg p-6 border border-theme-primary/20">
<h3 class="text-xl font-bold text-theme-text mb-6 flex items-center gap-2">
<span>📜</span> Vollständiges Transkript
</h3>
<div class="prose prose-lg max-w-none text-theme-text" set:html={item.transcriptContent} />
</div>
</div>
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all content data for filtering
const contentData = {JSON.stringify(combinedData)};
let filteredContent = [...contentData];
let activeContentType = 'both';
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const contentContainer = document.getElementById('contentContainer');
const resultsCount = document.getElementById('resultsCount');
const filteredWords = document.getElementById('filteredWords');
const totalWords = document.getElementById('totalWords');
const currentSelection = document.getElementById('currentSelection');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllContent');
const copyFilteredBtn = document.getElementById('copyFilteredContent');
const clearFiltersBtn = document.getElementById('clearFilters');
// Content Type Management
function switchContentType(contentType) {
activeContentType = contentType;
// Update toggle appearance
document.querySelectorAll('.content-toggle').forEach(toggle => {
if (toggle.dataset.content === contentType) {
toggle.classList.add('active', 'bg-theme-accent', 'text-white');
toggle.classList.remove('bg-theme-card', 'border');
} else {
toggle.classList.remove('active', 'bg-theme-accent', 'text-white');
toggle.classList.add('bg-theme-card', 'border', 'border-theme-border/30');
}
});
// Show/hide content sections
document.querySelectorAll('.content-type').forEach(section => {
if ((contentType === 'both' && section.classList.contains('both-content')) ||
(contentType === 'analysis' && section.classList.contains('analysis-only')) ||
(contentType === 'transcript' && section.classList.contains('transcript-only'))) {
section.style.display = 'block';
} else {
section.style.display = 'none';
}
});
// Update selection text
const selectionNames = {
'both': 'Analyse + Transkript',
'analysis': 'Nur Analysen',
'transcript': 'Nur Transkripte'
};
currentSelection.textContent = selectionNames[contentType];
updateWordCount();
}
function updateWordCount() {
const visibleContent = filteredContent.length > 0 ? filteredContent : contentData;
let wordCount = 0;
visibleContent.forEach(item => {
switch(activeContentType) {
case 'both':
wordCount += (item.readingTime * 250); // Estimate words for full content
break;
case 'analysis':
wordCount += (item.readingTime * 100); // Estimate words for analysis only
break;
case 'transcript':
wordCount += (item.readingTime * 150); // Estimate words for transcript only
break;
}
});
filteredWords.textContent = wordCount.toLocaleString();
}
// Filter Functions
function applyFilters() {
const contentItems = document.querySelectorAll('.content-item');
let visibleCount = 0;
filteredContent = contentData.filter(item => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = item.title.toLowerCase().includes(searchLower);
const matchesAnalysis = item.analysisContent.toLowerCase().includes(searchLower);
const matchesTranscript = item.transcriptContent.toLowerCase().includes(searchLower);
const matchesSummary = item.summary.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesAnalysis && !matchesTranscript && !matchesSummary) return false;
}
// Year filter
if (activeFilters.year && item.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && item.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && item.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => item.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide content items
contentItems.forEach(item => {
const contentId = item.querySelector('.copy-single-content').dataset.contentId;
const isVisible = filteredContent.some(content => content.id === contentId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update counters
resultsCount.textContent = visibleCount;
updateWordCount();
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
contentContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
contentContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'inline-flex' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Content type toggles
document.querySelectorAll('.content-toggle').forEach(toggle => {
toggle.addEventListener('click', (e) => {
switchContentType(e.target.dataset.content);
});
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
switch(preset) {
case 'recent':
clearAllFilters();
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
applyFilters();
break;
case 'long':
clearAllFilters();
// Filter for talks longer than 15 minutes reading time
filteredContent = contentData.filter(item => item.readingTime > 15);
applyFilters();
break;
case 'psychology':
clearAllFilters();
activeFilters.tags = ['psychology'];
document.querySelector('[data-tag="psychology"]')?.classList.add('active');
applyFilters();
break;
}
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
function getContentByType(item) {
switch(activeContentType) {
case 'analysis':
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.analysisContent}`;
case 'transcript':
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.transcriptContent}`;
default:
return `# ${item.title}\\n\\nDatum: ${new Date(item.date).toLocaleDateString('de-DE')}\\nVenue: ${item.venue}\\nDauer: ${item.duration}\\n\\n${item.fullContent}`;
}
}
// Copy all content
copyAllBtn.addEventListener('click', async (e) => {
const allContent = contentData.map(item =>
`${getContentByType(item)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(allContent);
showCopyFeedback(e.target, success);
});
// Copy filtered content
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredContentText = filteredContent.map(item =>
`${getContentByType(item)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(filteredContentText);
showCopyFeedback(e.target, success);
});
// Copy single content
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-content')) {
const button = e.target.closest('.copy-single-content');
const contentId = button.dataset.contentId;
const item = contentData.find(c => c.id === contentId);
if (item) {
const content = getContentByType(item);
const success = await copyToClipboard(content);
showCopyFeedback(button, success);
}
}
});
// Add active styles
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
.content-toggle.active {
background-color: var(--theme-accent) !important;
color: white !important;
border-color: var(--theme-accent) !important;
}
`;
document.head.appendChild(style);
// Initialize word count
updateWordCount();
</script>
</body>
</html>

View file

@ -0,0 +1,650 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Extract analysis content (everything except transcript)
const analysesData = speakerTalks.map(talk => {
const content = talk.compiledContent();
// Split content at transcript marker
const transcriptMarker = '## 📜 Full Transcript';
const [analysisContent] = content.split(transcriptMarker);
// Extract different sections
const sections = {
summary: extractSection(analysisContent, '## Executive Summary'),
insights: extractSection(analysisContent, '## 🎯 Key Insights'),
quotes: extractSection(analysisContent, '## 💡 Memorable Quotes'),
concepts: extractSection(analysisContent, '## 📚 Core Concepts'),
chapters: extractSection(analysisContent, '## 🎬 Chapter Breakdown'),
takeaways: extractSection(analysisContent, '## 🚀 Practical Takeaways'),
related: extractSection(analysisContent, '## 🔗 Related Ideas'),
reflection: extractSection(analysisContent, '## 💭 Reflection Questions')
};
return {
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
summary: talk.frontmatter.summary,
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0,
fullAnalysis: analysisContent,
sections
};
});
// Helper function to extract sections
function extractSection(content, marker) {
const startIndex = content.indexOf(marker);
if (startIndex === -1) return '';
const afterMarker = content.substring(startIndex + marker.length);
const nextMarkerIndex = afterMarker.search(/##\s+[🎯💡📚🎬🚀🔗💭]/);
if (nextMarkerIndex === -1) {
return afterMarker.trim();
} else {
return afterMarker.substring(0, nextMarkerIndex).trim();
}
}
// Get unique filter options
const categories = [...new Set(analysesData.map(talk => talk.category))];
const years = [...new Set(analysesData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(analysesData.map(talk => talk.venue))];
const allTags = [...new Set(analysesData.flatMap(talk => talk.tags))];
---
<!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>{speakerData.name} - Alle Analysen & Insights | YouTube Wisdom Library</title>
<meta name="description" content="Alle Analysen, Insights und Zusammenfassungen von {speakerData.name} - kompakt und kopierbar.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-secondary/10 to-theme-accent/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Alle Analysen von {speakerData.name}
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{analysesData.length} Vorträge • Insights, Quotes & Zusammenfassungen
</p>
<!-- Content Type Tabs -->
<div class="flex flex-wrap justify-center gap-2 mb-8">
<button class="content-tab active bg-theme-primary text-white px-4 py-2 rounded-lg transition-all" data-content="all">
📊 Alle Inhalte
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="summary">
📝 Zusammenfassungen
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="insights">
💡 Key Insights
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="quotes">
💬 Zitate
</button>
<button class="content-tab bg-theme-card border border-theme-border/30 px-4 py-2 rounded-lg hover:bg-theme-primary/10 transition-all" data-content="takeaways">
🚀 Takeaways
</button>
</div>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllAnalyses"
class="bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alle Analysen kopieren
</button>
<button
id="copyFilteredAnalyses"
class="bg-theme-accent text-white px-6 py-3 rounded-lg hover:bg-theme-accent-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefilterte Analysen kopieren
</button>
<button
id="copyCurrentContent"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📑</span> Aktuelle Auswahl kopieren
</button>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Analysen..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="insights">
💡 Nur Key Insights
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="quotes">
💬 Nur Zitate
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{analysesData.length}</span> von {analysesData.length} Analysen angezeigt
• Aktuelle Auswahl: <span id="currentContentType">Alle Inhalte</span>
</div>
</div>
</div>
</div>
</div>
<!-- Analyses Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="analysesContainer" class="space-y-8">
{analysesData.map(analysis => (
<article
class="analysis-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={analysis.title.toLowerCase()}
data-year={analysis.year}
data-category={analysis.category}
data-venue={analysis.venue}
data-tags={JSON.stringify(analysis.tags)}
data-reading-time={analysis.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-theme-text">
{analysis.title}
</h2>
<button
class="copy-single-analysis bg-theme-secondary/10 text-theme-secondary px-4 py-2 rounded-lg hover:bg-theme-secondary/20 transition-all flex items-center gap-2 text-sm font-medium"
data-analysis-id={analysis.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted mb-4">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(analysis.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {analysis.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {analysis.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {analysis.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mb-4">
{analysis.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
<!-- Summary Preview -->
<div class="bg-theme-background/50 rounded-lg p-4">
<p class="text-theme-text-muted italic">
{analysis.summary}
</p>
</div>
</div>
<!-- Analysis Content -->
<div class="analysis-content">
<!-- All Content (default) -->
<div class="content-section" data-section="all">
<div class="prose prose-lg max-w-none" set:html={analysis.fullAnalysis} />
</div>
<!-- Summary Only -->
<div class="content-section" data-section="summary" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.summary} />
</div>
<!-- Insights Only -->
<div class="content-section" data-section="insights" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.insights} />
</div>
<!-- Quotes Only -->
<div class="content-section" data-section="quotes" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.quotes} />
</div>
<!-- Takeaways Only -->
<div class="content-section" data-section="takeaways" style="display: none;">
<div class="prose prose-lg max-w-none" set:html={analysis.sections.takeaways} />
</div>
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all analyses data for filtering
const analysesData = {JSON.stringify(analysesData)};
let filteredAnalyses = [...analysesData];
let activeContentType = 'all';
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const analysesContainer = document.getElementById('analysesContainer');
const resultsCount = document.getElementById('resultsCount');
const currentContentType = document.getElementById('currentContentType');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllAnalyses');
const copyFilteredBtn = document.getElementById('copyFilteredAnalyses');
const copyCurrentBtn = document.getElementById('copyCurrentContent');
const clearFiltersBtn = document.getElementById('clearFilters');
// Content Type Management
function switchContentType(contentType) {
activeContentType = contentType;
// Update tab appearance
document.querySelectorAll('.content-tab').forEach(tab => {
if (tab.dataset.content === contentType) {
tab.classList.add('active', 'bg-theme-primary', 'text-white');
tab.classList.remove('bg-theme-card', 'border');
} else {
tab.classList.remove('active', 'bg-theme-primary', 'text-white');
tab.classList.add('bg-theme-card', 'border', 'border-theme-border/30');
}
});
// Show/hide content sections
document.querySelectorAll('.content-section').forEach(section => {
if (section.dataset.section === contentType) {
section.style.display = 'block';
} else {
section.style.display = 'none';
}
});
// Update counter text
const contentTypeNames = {
'all': 'Alle Inhalte',
'summary': 'Zusammenfassungen',
'insights': 'Key Insights',
'quotes': 'Zitate',
'takeaways': 'Takeaways'
};
currentContentType.textContent = contentTypeNames[contentType];
}
// Filter Functions
function applyFilters() {
const analysisItems = document.querySelectorAll('.analysis-item');
let visibleCount = 0;
filteredAnalyses = analysesData.filter(analysis => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = analysis.title.toLowerCase().includes(searchLower);
const matchesAnalysis = analysis.fullAnalysis.toLowerCase().includes(searchLower);
const matchesSummary = analysis.summary.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesAnalysis && !matchesSummary) return false;
}
// Year filter
if (activeFilters.year && analysis.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && analysis.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && analysis.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => analysis.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide analysis items
analysisItems.forEach(item => {
const analysisId = item.querySelector('.copy-single-analysis').dataset.analysisId;
const isVisible = filteredAnalyses.some(analysis => analysis.id === analysisId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
} else {
item.style.display = 'none';
}
});
// Update counter
resultsCount.textContent = visibleCount;
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
analysesContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
analysesContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'inline-flex' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Content type tabs
document.querySelectorAll('.content-tab').forEach(tab => {
tab.addEventListener('click', (e) => {
switchContentType(e.target.dataset.content);
});
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
switch(preset) {
case 'recent':
clearAllFilters();
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
applyFilters();
break;
case 'insights':
switchContentType('insights');
break;
case 'quotes':
switchContentType('quotes');
break;
}
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
function getContentByType(analysis, contentType) {
switch(contentType) {
case 'summary':
return `# ${analysis.title}\\n\\n${analysis.summary}\\n\\n${analysis.sections.summary}`;
case 'insights':
return `# ${analysis.title}\\n\\n${analysis.sections.insights}`;
case 'quotes':
return `# ${analysis.title}\\n\\n${analysis.sections.quotes}`;
case 'takeaways':
return `# ${analysis.title}\\n\\n${analysis.sections.takeaways}`;
default:
return `# ${analysis.title}\\n\\n${analysis.fullAnalysis}`;
}
}
// Copy all analyses
copyAllBtn.addEventListener('click', async (e) => {
const allAnalyses = analysesData.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(allAnalyses);
showCopyFeedback(e.target, success);
});
// Copy filtered analyses
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredAnalysesContent = filteredAnalyses.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(filteredAnalysesContent);
showCopyFeedback(e.target, success);
});
// Copy current content selection
copyCurrentBtn.addEventListener('click', async (e) => {
const currentAnalyses = filteredAnalyses.length > 0 ? filteredAnalyses : analysesData;
const currentContent = currentAnalyses.map(analysis =>
`${getContentByType(analysis, activeContentType)}\\n\\n---\\n\\n`
).join('');
const success = await copyToClipboard(currentContent);
showCopyFeedback(e.target, success);
});
// Copy single analysis
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-analysis')) {
const button = e.target.closest('.copy-single-analysis');
const analysisId = button.dataset.analysisId;
const analysis = analysesData.find(a => a.id === analysisId);
if (analysis) {
const content = getContentByType(analysis, activeContentType);
const success = await copyToClipboard(content);
showCopyFeedback(button, success);
}
}
});
// Add active styles for tag filters
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
.content-tab.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View file

@ -0,0 +1,488 @@
---
import Navigation from '../../../components/Navigation.astro';
import Footer from '../../../components/Footer.astro';
import ThemeSwitcher from '../../../components/ThemeSwitcher.astro';
import '../../../styles/themes.css';
const currentPath = Astro.url.pathname;
// Get all talks for this speaker from content collection
const allTalks = await Astro.glob('../../../content/talks/*.md');
const speakerTalks = allTalks.filter(talk =>
talk.frontmatter.speakerId === 'rory-sutherland'
);
const speakerData = {
name: "Rory Sutherland",
title: "Vice Chairman",
company: "Ogilvy UK"
};
// Process talks data for filtering
const talksData = speakerTalks.map(talk => ({
id: talk.frontmatter.speakerId + '-' + talk.frontmatter.title.toLowerCase().replace(/\s+/g, '-'),
title: talk.frontmatter.title,
date: talk.frontmatter.date,
category: talk.frontmatter.category,
tags: talk.frontmatter.tags || [],
venue: talk.frontmatter.venue,
duration: talk.frontmatter.duration,
transcript: talk.compiledContent(),
year: new Date(talk.frontmatter.date).getFullYear(),
readingTime: talk.frontmatter.readingTime || 0
}));
// Get unique filter options
const categories = [...new Set(talksData.map(talk => talk.category))];
const years = [...new Set(talksData.map(talk => talk.year))].sort((a, b) => b - a);
const venues = [...new Set(talksData.map(talk => talk.venue))];
const allTags = [...new Set(talksData.flatMap(talk => talk.tags))];
---
<!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>{speakerData.name} - Alle Transkripte | YouTube Wisdom Library</title>
<meta name="description" content="Alle Transkripte von {speakerData.name} auf einer Seite - durchsuchbar und komplett kopierbar.">
</head>
<body class="bg-theme-background text-theme-text min-h-screen">
<ThemeSwitcher />
<Navigation currentPath={currentPath} />
<!-- Header -->
<div class="bg-gradient-to-br from-theme-primary/10 to-theme-secondary/10 py-16">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="text-center">
<h1 class="text-4xl md:text-5xl font-bold text-theme-text mb-4">
Alle Transkripte von {speakerData.name}
</h1>
<p class="text-xl text-theme-text-muted mb-8">
{talksData.length} Vorträge • Komplett durchsuchbar und kopierbar
</p>
<!-- Bulk Actions -->
<div class="flex flex-wrap justify-center gap-4">
<button
id="copyAllTranscripts"
class="bg-theme-primary text-white px-6 py-3 rounded-lg hover:bg-theme-primary-dark transition-colors font-semibold flex items-center gap-2"
>
<span>📋</span> Alle Transkripte kopieren
</button>
<button
id="copyFilteredTranscripts"
class="bg-theme-secondary text-white px-6 py-3 rounded-lg hover:bg-theme-secondary-dark transition-colors font-semibold flex items-center gap-2"
style="display: none;"
>
<span>🎯</span> Gefilterte Transkripte kopieren
</button>
<div class="text-sm text-theme-text-muted mt-2">
Geschätzte Lesezeit: <span id="totalReadingTime">{talksData.reduce((sum, talk) => sum + talk.readingTime, 0)} Min</span>
</div>
</div>
</div>
</div>
</div>
<!-- Filter Section -->
<div class="bg-theme-card/30 py-8 sticky top-0 z-40 backdrop-blur-sm border-b border-theme-border/20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-theme-card rounded-xl p-6 border border-theme-border/20">
<h2 class="text-lg font-semibold text-theme-text mb-4 flex items-center gap-2">
<span>🔍</span> Filter & Suche
</h2>
<div class="grid md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
<!-- Search -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Volltext-Suche</label>
<input
type="text"
id="searchInput"
placeholder="Suche in allen Transkripten..."
class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50 focus:border-transparent"
>
</div>
<!-- Year Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Jahr</label>
<select id="yearFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Jahre</option>
{years.map(year => (
<option value={year}>{year}</option>
))}
</select>
</div>
<!-- Category Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Kategorie</label>
<select id="categoryFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Kategorien</option>
{categories.map(category => (
<option value={category}>{category}</option>
))}
</select>
</div>
<!-- Venue Filter -->
<div>
<label class="block text-sm font-medium text-theme-text-muted mb-2">Venue</label>
<select id="venueFilter" class="w-full px-3 py-2 bg-theme-background border border-theme-border/30 rounded-lg focus:ring-2 focus:ring-theme-primary/50">
<option value="">Alle Venues</option>
{venues.map(venue => (
<option value={venue}>{venue}</option>
))}
</select>
</div>
</div>
<!-- Tags Filter -->
<div class="mb-4">
<label class="block text-sm font-medium text-theme-text-muted mb-2">Tags</label>
<div class="flex flex-wrap gap-2" id="tagsContainer">
{allTags.map(tag => (
<button
class="tag-filter px-3 py-1 text-sm bg-theme-background border border-theme-border/30 rounded-full hover:bg-theme-primary/10 hover:border-theme-primary/50 transition-all"
data-tag={tag}
>
{tag}
</button>
))}
</div>
</div>
<!-- Quick Filters -->
<div class="flex flex-wrap gap-2">
<button class="quick-filter px-4 py-2 text-sm bg-theme-primary/10 text-theme-primary rounded-lg hover:bg-theme-primary/20 transition-all" data-preset="recent">
🕒 Letzte 2 Jahre
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-secondary/10 text-theme-secondary rounded-lg hover:bg-theme-secondary/20 transition-all" data-preset="popular">
🔥 Meist gelesen
</button>
<button class="quick-filter px-4 py-2 text-sm bg-theme-accent/10 text-theme-accent rounded-lg hover:bg-theme-accent/20 transition-all" data-preset="long">
📖 Lange Vorträge
</button>
<button id="clearFilters" class="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded-lg hover:bg-gray-200 transition-all">
❌ Filter zurücksetzen
</button>
</div>
<!-- Results Counter -->
<div class="mt-4 pt-4 border-t border-theme-border/20">
<div class="text-sm text-theme-text-muted">
<span id="resultsCount">{talksData.length}</span> von {talksData.length} Transkripten angezeigt
• <span id="filteredReadingTime">{talksData.reduce((sum, talk) => sum + talk.readingTime, 0)} Min</span> Lesezeit
</div>
</div>
</div>
</div>
</div>
<!-- Transcripts Content -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div id="transcriptsContainer" class="space-y-8">
{talksData.map(talk => (
<article
class="transcript-item bg-theme-card rounded-xl p-8 border border-theme-border/20 shadow-sm"
data-title={talk.title.toLowerCase()}
data-year={talk.year}
data-category={talk.category}
data-venue={talk.venue}
data-tags={JSON.stringify(talk.tags)}
data-reading-time={talk.readingTime}
>
<!-- Talk Header -->
<div class="border-b border-theme-border/20 pb-6 mb-6">
<div class="flex justify-between items-start mb-4">
<h2 class="text-2xl font-bold text-theme-text">
{talk.title}
</h2>
<button
class="copy-single-transcript bg-theme-primary/10 text-theme-primary px-4 py-2 rounded-lg hover:bg-theme-primary/20 transition-all flex items-center gap-2 text-sm font-medium"
data-transcript-id={talk.id}
>
<span>📋</span> Kopieren
</button>
</div>
<div class="flex flex-wrap gap-4 text-sm text-theme-text-muted">
<span class="flex items-center gap-1">
<span>📅</span> {new Date(talk.date).toLocaleDateString('de-DE')}
</span>
<span class="flex items-center gap-1">
<span>🏛️</span> {talk.venue}
</span>
<span class="flex items-center gap-1">
<span>⏱️</span> {talk.duration}
</span>
<span class="flex items-center gap-1">
<span>📖</span> {talk.readingTime} Min Lesezeit
</span>
</div>
<div class="flex flex-wrap gap-2 mt-3">
{talk.tags.map(tag => (
<span class="px-2 py-1 text-xs bg-theme-primary/10 text-theme-primary rounded-full">
{tag}
</span>
))}
</div>
</div>
<!-- Transcript Content -->
<div class="transcript-content prose prose-lg max-w-none" data-transcript-text={talk.transcript}>
<div set:html={talk.transcript} />
</div>
</article>
))}
</div>
<!-- No Results -->
<div id="noResults" class="text-center py-12" style="display: none;">
<div class="text-6xl mb-4">🔍</div>
<h3 class="text-xl font-semibold text-theme-text mb-2">Keine Ergebnisse gefunden</h3>
<p class="text-theme-text-muted">Versuche andere Filter oder Suchbegriffe</p>
</div>
</div>
<Footer />
<script>
// Store all talks data for filtering
const talksData = {JSON.stringify(talksData)};
let filteredTalks = [...talksData];
let activeFilters = {
search: '',
year: '',
category: '',
venue: '',
tags: []
};
// DOM Elements
const searchInput = document.getElementById('searchInput');
const yearFilter = document.getElementById('yearFilter');
const categoryFilter = document.getElementById('categoryFilter');
const venueFilter = document.getElementById('venueFilter');
const tagsContainer = document.getElementById('tagsContainer');
const transcriptsContainer = document.getElementById('transcriptsContainer');
const resultsCount = document.getElementById('resultsCount');
const filteredReadingTime = document.getElementById('filteredReadingTime');
const noResults = document.getElementById('noResults');
const copyAllBtn = document.getElementById('copyAllTranscripts');
const copyFilteredBtn = document.getElementById('copyFilteredTranscripts');
const clearFiltersBtn = document.getElementById('clearFilters');
// Filter Functions
function applyFilters() {
const transcriptItems = document.querySelectorAll('.transcript-item');
let visibleCount = 0;
let totalReadingTime = 0;
filteredTalks = talksData.filter(talk => {
// Search filter
if (activeFilters.search) {
const searchLower = activeFilters.search.toLowerCase();
const matchesTitle = talk.title.toLowerCase().includes(searchLower);
const matchesTranscript = talk.transcript.toLowerCase().includes(searchLower);
if (!matchesTitle && !matchesTranscript) return false;
}
// Year filter
if (activeFilters.year && talk.year != activeFilters.year) return false;
// Category filter
if (activeFilters.category && talk.category !== activeFilters.category) return false;
// Venue filter
if (activeFilters.venue && talk.venue !== activeFilters.venue) return false;
// Tags filter
if (activeFilters.tags.length > 0) {
const hasTag = activeFilters.tags.some(tag => talk.tags.includes(tag));
if (!hasTag) return false;
}
return true;
});
// Show/hide transcript items
transcriptItems.forEach(item => {
const talkId = item.querySelector('.copy-single-transcript').dataset.transcriptId;
const isVisible = filteredTalks.some(talk => talk.id === talkId);
if (isVisible) {
item.style.display = 'block';
visibleCount++;
totalReadingTime += parseInt(item.dataset.readingTime) || 0;
} else {
item.style.display = 'none';
}
});
// Update counter
resultsCount.textContent = visibleCount;
filteredReadingTime.textContent = totalReadingTime;
// Show/hide no results
if (visibleCount === 0) {
noResults.style.display = 'block';
transcriptsContainer.style.display = 'none';
} else {
noResults.style.display = 'none';
transcriptsContainer.style.display = 'block';
}
// Show/hide filtered copy button
const hasActiveFilters = Object.values(activeFilters).some(filter =>
Array.isArray(filter) ? filter.length > 0 : filter !== ''
);
copyFilteredBtn.style.display = hasActiveFilters ? 'block' : 'none';
}
// Event Listeners
searchInput.addEventListener('input', (e) => {
activeFilters.search = e.target.value;
applyFilters();
});
yearFilter.addEventListener('change', (e) => {
activeFilters.year = e.target.value;
applyFilters();
});
categoryFilter.addEventListener('change', (e) => {
activeFilters.category = e.target.value;
applyFilters();
});
venueFilter.addEventListener('change', (e) => {
activeFilters.venue = e.target.value;
applyFilters();
});
// Tag filters
tagsContainer.addEventListener('click', (e) => {
if (e.target.classList.contains('tag-filter')) {
const tag = e.target.dataset.tag;
if (activeFilters.tags.includes(tag)) {
activeFilters.tags = activeFilters.tags.filter(t => t !== tag);
e.target.classList.remove('active');
} else {
activeFilters.tags.push(tag);
e.target.classList.add('active');
}
applyFilters();
}
});
// Quick filters
document.querySelectorAll('.quick-filter').forEach(btn => {
btn.addEventListener('click', (e) => {
const preset = e.target.dataset.preset;
clearAllFilters();
switch(preset) {
case 'recent':
activeFilters.year = new Date().getFullYear();
yearFilter.value = activeFilters.year;
break;
case 'popular':
// Sort by reading time and show top ones
break;
case 'long':
// Filter talks longer than 15 minutes reading time
break;
}
applyFilters();
});
});
// Clear filters
clearFiltersBtn.addEventListener('click', clearAllFilters);
function clearAllFilters() {
activeFilters = { search: '', year: '', category: '', venue: '', tags: [] };
searchInput.value = '';
yearFilter.value = '';
categoryFilter.value = '';
venueFilter.value = '';
document.querySelectorAll('.tag-filter.active').forEach(tag => {
tag.classList.remove('active');
});
applyFilters();
}
// Copy functions
async function copyToClipboard(text) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('Failed to copy text: ', err);
return false;
}
}
function showCopyFeedback(button, success = true) {
const originalText = button.innerHTML;
button.innerHTML = success ? '<span>✅</span> Kopiert!' : '<span>❌</span> Fehler';
button.disabled = true;
setTimeout(() => {
button.innerHTML = originalText;
button.disabled = false;
}, 2000);
}
// Copy all transcripts
copyAllBtn.addEventListener('click', async (e) => {
const allTranscripts = talksData.map(talk =>
`# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}\n\n---\n\n`
).join('');
const success = await copyToClipboard(allTranscripts);
showCopyFeedback(e.target, success);
});
// Copy filtered transcripts
copyFilteredBtn.addEventListener('click', async (e) => {
const filteredTranscripts = filteredTalks.map(talk =>
`# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}\n\n---\n\n`
).join('');
const success = await copyToClipboard(filteredTranscripts);
showCopyFeedback(e.target, success);
});
// Copy single transcript
document.addEventListener('click', async (e) => {
if (e.target.closest('.copy-single-transcript')) {
const button = e.target.closest('.copy-single-transcript');
const transcriptId = button.dataset.transcriptId;
const talk = talksData.find(t => t.id === transcriptId);
if (talk) {
const transcript = `# ${talk.title}\nDatum: ${new Date(talk.date).toLocaleDateString('de-DE')}\nVenue: ${talk.venue}\nDauer: ${talk.duration}\n\n${talk.transcript}`;
const success = await copyToClipboard(transcript);
showCopyFeedback(button, success);
}
}
});
// Add active styles for tag filters
const style = document.createElement('style');
style.textContent = `
.tag-filter.active {
background-color: var(--theme-primary) !important;
color: white !important;
border-color: var(--theme-primary) !important;
}
`;
document.head.appendChild(style);
</script>
</body>
</html>

View file

@ -0,0 +1,287 @@
---
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import SpeakerHero from '../../components/speakers/SpeakerHero.astro';
import SpeakerStats from '../../components/speakers/SpeakerStats.astro';
import QuoteCollection from '../../components/speakers/QuoteCollection.astro';
import TalkGrid from '../../components/speakers/TalkGrid.astro';
import '../../styles/themes.css';
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"
};
const statsData = {
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"
}
];
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"
}
];
// 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: "♾️"
}
];
---
<!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>

View file

@ -0,0 +1,620 @@
---
import { getCollection } from 'astro:content';
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
import ThemeSwitcher from '../../components/ThemeSwitcher.astro';
import CollapsibleSection from '../../components/CollapsibleSection.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 { talk } = Astro.props;
const { Content } = await talk.render();
const currentPath = Astro.url.pathname;
// Parse the rendered content to extract sections
const contentHtml = await talk.render().then(result => result.Content);
---
<!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>
.article-header {
background: linear-gradient(135deg, rgba(var(--theme-primary), 0.1) 0%, rgba(var(--theme-secondary), 0.05) 100%);
padding: 4rem 0 3rem;
margin-bottom: 3rem;
position: relative;
overflow: hidden;
}
.article-header::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
pointer-events: none;
}
.article-container {
max-width: 900px;
margin: 0 auto;
padding: 0 2rem;
position: relative;
z-index: 1;
}
.header-content {
text-align: center;
}
h1 {
color: rgb(var(--theme-primary));
font-size: 2.8rem;
margin-bottom: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.meta {
display: flex;
justify-content: center;
gap: 2.5rem;
color: rgb(var(--theme-text-muted));
margin-bottom: 1.5rem;
font-size: 1.1rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.speaker {
font-weight: 600;
text-decoration: none;
color: inherit;
transition: color 0.2s ease;
position: relative;
z-index: 10;
}
a.speaker {
cursor: pointer;
text-decoration: none;
}
a.speaker:hover {
color: rgb(var(--theme-primary));
text-decoration: underline;
}
.tags {
display: flex;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 1.5rem;
}
.tag {
background: rgb(var(--theme-card));
backdrop-filter: blur(10px);
padding: 0.4rem 1rem;
border-radius: 2rem;
font-size: 0.9rem;
color: rgb(var(--theme-primary));
border: 1px solid rgba(var(--theme-primary), 0.2);
transition: all 0.2s ease;
}
.tag:hover {
background: rgba(var(--theme-primary), 0.1);
transform: translateY(-2px);
}
.summary-card {
background: rgb(var(--theme-card));
padding: 2rem;
border-radius: 1rem;
margin-bottom: 2rem;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: 1.1rem;
line-height: 1.7;
color: rgb(var(--theme-text));
border-left: 4px solid rgb(var(--theme-primary));
}
.back-link {
display: inline-flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 2rem;
color: rgb(var(--theme-primary));
text-decoration: none;
font-weight: 600;
transition: gap 0.2s ease;
}
.back-link:hover {
gap: 0.75rem;
}
.content-sections {
margin-top: 3rem;
}
/* Styles for content inside sections */
:global(.section-inner h3) {
color: rgb(var(--theme-primary));
margin: 1.5rem 0 1rem;
font-size: 1.3rem;
}
:global(.section-inner p) {
margin-bottom: 1.25rem;
line-height: 1.7;
color: rgb(var(--theme-text));
}
:global(.section-inner ul, .section-inner ol) {
margin: 1rem 0 1.5rem;
padding-left: 2rem;
}
:global(.section-inner li) {
margin-bottom: 0.75rem;
line-height: 1.7;
}
:global(.section-inner blockquote) {
background: rgba(var(--theme-primary), 0.05);
border-left: 4px solid rgb(var(--theme-primary));
padding: 1.25rem 1.5rem;
margin: 1.5rem 0;
border-radius: 0.5rem;
font-style: italic;
}
:global(.section-inner blockquote p) {
margin-bottom: 0;
}
:global(.section-inner strong) {
color: rgb(var(--theme-primary));
font-weight: 600;
}
:global(.section-inner hr) {
border: none;
border-top: 2px solid rgba(var(--theme-primary), 0.1);
margin: 2rem 0;
}
@media (max-width: 768px) {
h1 {
font-size: 2rem;
}
.meta {
flex-direction: column;
gap: 1rem;
}
.article-header {
padding: 3rem 0 2rem;
}
}
</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">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Zurück zur Übersicht
</a>
<div class="header-content">
<h1>{talk.data.title}</h1>
<div class="meta">
<div class="meta-item">
{talk.data.speakerId ? (
<a href={`/speakers/${talk.data.speakerId}`} class="speaker hover:text-theme-primary transition-colors">
<span>🎤</span>
<span>{talk.data.speaker}</span>
</a>
) : (
<span class="speaker">
<span>🎤</span>
<span>{talk.data.speaker}</span>
</span>
)}
</div>
<div class="meta-item">
<span>📅 {talk.data.venue}</span>
</div>
<div class="meta-item">
<span>⏱️ {talk.data.duration}</span>
</div>
</div>
<div class="tags">
{talk.data.tags.map((tag: string) => (
<span class="tag">{tag}</span>
))}
</div>
</div>
</div>
</div>
<div class="article-container">
<div class="summary-card">
{talk.data.summary}
</div>
<div class="content-sections" id="content-wrapper">
<!-- Content will be dynamically organized into sections -->
<div style="display: none;" id="raw-content">
<Content />
</div>
</div>
</div>
<Footer />
<script>
document.addEventListener('DOMContentLoaded', () => {
const rawContent = document.getElementById('raw-content');
const wrapper = document.getElementById('content-wrapper');
if (!rawContent || !wrapper) return;
// Get all h2 elements and their content
const sections = [];
const elements = Array.from(rawContent.children);
let currentSection = null;
elements.forEach(element => {
if (element.tagName === 'H2') {
// Start a new section
currentSection = {
title: element.textContent,
icon: getIconForSection(element.textContent),
defaultCollapsed: element.textContent.includes('Transcript') || element.textContent.includes('Transkript'),
content: []
};
sections.push(currentSection);
} else if (currentSection) {
currentSection.content.push(element.outerHTML);
}
});
// Create CollapsibleSection elements for each section
sections.forEach(section => {
const sectionEl = document.createElement('div');
sectionEl.className = 'collapsible-section';
sectionEl.setAttribute('data-section-id', section.title.toLowerCase().replace(/\s+/g, '-'));
sectionEl.innerHTML = `
<button class="section-header" aria-expanded="${!section.defaultCollapsed}">
<span class="section-icon">${section.icon}</span>
<h2 class="section-title">${section.title}</h2>
<span class="section-arrow" data-collapsed="${section.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 class="section-content" data-collapsed="${section.defaultCollapsed}">
<div class="section-inner">
${section.content.join('')}
</div>
</div>
`;
wrapper.appendChild(sectionEl);
// Add click handler
const header = sectionEl.querySelector('.section-header');
const content = sectionEl.querySelector('.section-content');
const arrow = sectionEl.querySelector('.section-arrow');
const inner = content.querySelector('.section-inner');
// Set initial state first
if (section.defaultCollapsed) {
content.style.maxHeight = '0px';
content.style.overflow = 'hidden';
content.dataset.collapsed = 'true';
arrow.dataset.collapsed = 'true';
header.setAttribute('aria-expanded', 'false');
} else {
content.dataset.collapsed = 'false';
arrow.dataset.collapsed = 'false';
header.setAttribute('aria-expanded', 'true');
// Use setTimeout to ensure DOM is ready
setTimeout(() => {
const height = inner.scrollHeight;
content.style.maxHeight = height + 'px';
}, 10);
}
header.addEventListener('click', () => {
const isCollapsed = content.dataset.collapsed === 'true';
if (isCollapsed) {
// Expanding
content.dataset.collapsed = 'false';
arrow.dataset.collapsed = 'false';
header.setAttribute('aria-expanded', 'true');
// First set to current height to enable transition
const targetHeight = inner.scrollHeight;
content.style.maxHeight = targetHeight + 'px';
content.style.overflow = 'hidden';
} else {
// Collapsing
// Get current height
const currentHeight = inner.scrollHeight;
// Set explicit height first
content.style.maxHeight = currentHeight + 'px';
content.style.overflow = 'hidden';
// Force browser to acknowledge the height
content.offsetHeight;
// Then animate to 0
requestAnimationFrame(() => {
content.dataset.collapsed = 'true';
arrow.dataset.collapsed = 'true';
header.setAttribute('aria-expanded', 'false');
content.style.maxHeight = '0px';
});
}
});
});
// Remove the raw content
rawContent.remove();
});
function getIconForSection(title) {
const lower = title.toLowerCase();
if (lower.includes('summary')) return '📋';
if (lower.includes('insight') || lower.includes('key')) return '🎯';
if (lower.includes('quote')) return '💬';
if (lower.includes('concept')) return '📚';
if (lower.includes('chapter') || lower.includes('breakdown')) return '🎬';
if (lower.includes('takeaway')) return '🚀';
if (lower.includes('related')) return '🔗';
if (lower.includes('reflection') || lower.includes('question')) return '💭';
if (lower.includes('transcript') || lower.includes('transkript')) return '📜';
return '📌';
}
</script>
<style>
.collapsible-section {
background: rgb(var(--theme-card));
border-radius: 1.2rem;
margin-bottom: 1.8rem;
overflow: hidden;
border: 1px solid rgba(var(--theme-primary), 0.08);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: all 0.3s ease;
position: relative;
}
.collapsible-section::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 3px;
background: linear-gradient(90deg,
rgba(var(--theme-primary), 0.3) 0%,
rgba(var(--theme-secondary), 0.2) 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.collapsible-section:hover {
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
border-color: rgba(var(--theme-primary), 0.15);
transform: translateY(-2px);
}
.collapsible-section:hover::before {
opacity: 1;
}
.section-header {
width: 100%;
padding: 1.75rem 2rem;
background: transparent;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 1.25rem;
text-align: left;
transition: all 0.2s ease;
position: relative;
}
.section-header::after {
content: '';
position: absolute;
bottom: 0;
left: 2rem;
right: 2rem;
height: 1px;
background: linear-gradient(90deg,
transparent 0%,
rgba(var(--theme-primary), 0.1) 50%,
transparent 100%);
opacity: 0;
transition: opacity 0.3s ease;
}
.section-content[data-collapsed="false"] + * .section-header::after,
.section-header:hover::after {
opacity: 1;
}
.section-header:hover {
background: linear-gradient(135deg,
rgba(var(--theme-primary), 0.03) 0%,
rgba(var(--theme-secondary), 0.02) 100%);
}
.section-icon {
font-size: 1.6rem;
flex-shrink: 0;
width: 45px;
height: 45px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg,
rgba(var(--theme-primary), 0.1) 0%,
rgba(var(--theme-secondary), 0.08) 100%);
border-radius: 0.75rem;
transition: all 0.3s ease;
}
.section-header:hover .section-icon {
transform: scale(1.1);
background: linear-gradient(135deg,
rgba(var(--theme-primary), 0.15) 0%,
rgba(var(--theme-secondary), 0.12) 100%);
}
.section-title {
flex: 1;
margin: 0;
font-size: 1.45rem;
font-weight: 600;
color: rgb(var(--theme-primary));
letter-spacing: -0.02em;
}
.section-arrow {
flex-shrink: 0;
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
color: rgb(var(--theme-primary));
opacity: 0.7;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(var(--theme-primary), 0.05);
border-radius: 50%;
}
.section-header:hover .section-arrow {
opacity: 1;
background: rgba(var(--theme-primary), 0.1);
}
.section-arrow[data-collapsed="true"] {
transform: rotate(-90deg);
}
.section-arrow svg {
width: 20px;
height: 20px;
}
.section-content {
overflow: hidden;
transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
}
.section-content[data-collapsed="false"] {
border-top: 1px solid rgba(var(--theme-primary), 0.06);
opacity: 1;
}
.section-content[data-collapsed="true"] {
max-height: 0 !important;
opacity: 0;
overflow: hidden !important;
padding: 0 !important;
margin: 0 !important;
border: none !important;
}
.section-inner {
padding: 1.5rem 2rem 2rem 2rem;
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Special styling for transcript section */
.collapsible-section[data-section-id*="transcript"] {
background: linear-gradient(135deg,
rgba(var(--theme-card), 1) 0%,
rgba(var(--theme-primary), 0.02) 100%);
}
.collapsible-section[data-section-id*="transcript"] .section-icon {
background: linear-gradient(135deg,
rgba(var(--theme-secondary), 0.15) 0%,
rgba(var(--theme-primary), 0.1) 100%);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.section-header {
padding: 1.5rem 1.5rem;
}
.section-inner {
padding: 1.25rem 1.5rem 1.5rem 1.5rem;
}
.section-title {
font-size: 1.25rem;
}
.section-icon {
width: 40px;
height: 40px;
font-size: 1.4rem;
}
}
</style>
</body>
</html>

View file

@ -0,0 +1,281 @@
---
import { getCollection } from 'astro:content';
import Navigation from '../../components/Navigation.astro';
import Footer from '../../components/Footer.astro';
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 { talk } = Astro.props;
const { Content } = await talk.render();
const currentPath = Astro.url.pathname;
---
<!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;
}
.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>

View file

@ -0,0 +1,348 @@
---
import { getCollection } from 'astro:content';
import TalksSidebar from '../../components/TalksSidebar.astro';
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 { talk } = Astro.props;
const { Content } = await talk.render();
---
<!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;
}
.app-container {
display: flex;
height: 100vh;
background: rgb(var(--theme-background));
}
.main-content {
flex: 1;
margin-left: 320px;
overflow-y: auto;
position: relative;
}
.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);
}
.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: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;
}
.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;
}
.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 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 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 ul, .content-body ol {
margin: 1.5rem 0;
padding-left: 2rem;
}
.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 p {
margin: 0;
}
.content-body strong {
color: rgb(var(--theme-primary));
font-weight: 600;
}
.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;
}
.section-wrapper {
margin-bottom: 2rem;
}
.section-collapsed {
display: none;
}
.collapse-arrow {
transition: transform 0.3s ease;
font-size: 1.2rem;
}
.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;
}
@media (max-width: 1024px) {
.main-content {
margin-left: 0;
}
.content-wrapper {
padding: 2rem 1.5rem;
}
.mobile-menu-toggle {
display: block;
}
h1 {
font-size: 2rem;
}
}
@media (max-width: 768px) {
.content-wrapper {
padding: 1.5rem 1rem;
}
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>
<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="highlight-box">
💡 {talk.data.summary}
</div>
<div class="content-body" id="content">
<Content />
</div>
</div>
</main>
</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');
});
}
// 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);
// 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>

View file

@ -0,0 +1,407 @@
---
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 { talk } = Astro.props;
const { Content } = await talk.render();
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="highlight-box">
<p>{talk.data.summary}</p>
</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;
}
.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 a {
color: rgb(var(--theme-primary));
text-decoration: none;
}
.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;
}
.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: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;
}
.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 p {
margin: 0;
padding-left: 0.5rem;
}
.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: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 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 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::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 strong {
color: rgb(var(--theme-primary));
font-weight: 600;
}
.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;
}
.section-wrapper {
margin-bottom: 2rem;
}
.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.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:not(.section-collapsed) {
border-left-color: rgba(var(--theme-primary), 0.2);
}
@media (max-width: 1024px) {
.content-wrapper {
max-width: 100%;
padding: 1.5rem;
}
h1 {
font-size: 2.25rem;
line-height: 1.2;
}
.meta-info {
grid-template-columns: 1fr;
gap: 0.75rem;
}
.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;
}
h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
}
.content-body {
font-size: 1.05rem;
}
.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 p {
margin-bottom: 1.5rem;
}
.highlight-box {
padding: 1.5rem;
font-size: 1.05rem;
margin: 2rem 0;
}
.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>
<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);
// 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>
</BaseLayout>

View file

@ -0,0 +1,164 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
/* Ocean Theme - Light Mode */
[data-theme="ocean"] {
--color-primary: 0 102 204; /* #0066cc */
--color-primary-hover: 0 82 163; /* #0052a3 */
--color-secondary: 0 168 232; /* #00a8e8 */
--color-accent: 0 199 255; /* #00c7ff */
--color-background: 255 255 255; /* #ffffff */
--color-surface: 240 248 255; /* #f0f8ff - Alice Blue */
--color-surface-hover: 225 240 255; /* #e1f0ff */
--color-text: 26 26 26; /* #1a1a1a */
--color-text-muted: 102 102 102; /* #666666 */
--color-border: 200 220 240; /* #c8dcf0 */
--color-shadow: 0 51 102; /* #003366 */
}
/* Ocean Theme - Dark Mode */
[data-theme="ocean"].dark {
--color-primary: 0 168 232; /* #00a8e8 */
--color-primary-hover: 0 199 255; /* #00c7ff */
--color-secondary: 0 102 204; /* #0066cc */
--color-accent: 100 181 246; /* #64b5f6 */
--color-background: 10 25 41; /* #0a1929 */
--color-surface: 0 30 60; /* #001e3c */
--color-surface-hover: 0 40 80; /* #002850 */
--color-text: 224 224 224; /* #e0e0e0 */
--color-text-muted: 156 163 175; /* #9ca3af */
--color-border: 30 58 86; /* #1e3a56 */
--color-shadow: 0 0 0; /* #000000 */
}
/* Forest Theme - Light Mode */
[data-theme="forest"] {
--color-primary: 34 139 34; /* #228b22 - Forest Green */
--color-primary-hover: 25 111 25; /* #196f19 */
--color-secondary: 107 142 35; /* #6b8e23 - Olive Drab */
--color-accent: 154 205 50; /* #9acd32 - Yellow Green */
--color-background: 255 255 255; /* #ffffff */
--color-surface: 245 255 240; /* #f5fff0 - Honeydew */
--color-surface-hover: 235 248 230; /* #ebf8e6 */
--color-text: 26 26 26; /* #1a1a1a */
--color-text-muted: 85 107 47; /* #556b2f - Dark Olive Green */
--color-border: 188 220 188; /* #bcdcbc */
--color-shadow: 34 68 34; /* #224422 */
}
/* Forest Theme - Dark Mode */
[data-theme="forest"].dark {
--color-primary: 124 179 66; /* #7cb342 - Light Green */
--color-primary-hover: 139 195 74; /* #8bc34a */
--color-secondary: 85 139 47; /* #558b2f - Dark Green */
--color-accent: 178 223 138; /* #b2df8a */
--color-background: 18 28 18; /* #121c12 */
--color-surface: 28 48 28; /* #1c301c */
--color-surface-hover: 38 58 38; /* #263a26 */
--color-text: 224 224 224; /* #e0e0e0 */
--color-text-muted: 156 175 156; /* #9caf9c */
--color-border: 56 87 35; /* #385723 */
--color-shadow: 0 0 0; /* #000000 */
}
/* Sunset Theme - Light Mode */
[data-theme="sunset"] {
--color-primary: 255 87 34; /* #ff5722 - Deep Orange */
--color-primary-hover: 230 74 25; /* #e64a19 */
--color-secondary: 255 167 38; /* #ffa726 - Orange */
--color-accent: 255 193 7; /* #ffc107 - Amber */
--color-background: 255 255 255; /* #ffffff */
--color-surface: 255 248 241; /* #fff8f1 */
--color-surface-hover: 255 243 229; /* #fff3e5 */
--color-text: 26 26 26; /* #1a1a1a */
--color-text-muted: 121 85 72; /* #795548 - Brown */
--color-border: 255 224 203; /* #ffe0cb */
--color-shadow: 139 69 19; /* #8b4513 */
}
/* Sunset Theme - Dark Mode */
[data-theme="sunset"].dark {
--color-primary: 255 138 101; /* #ff8a65 - Light Deep Orange */
--color-primary-hover: 255 112 67; /* #ff7043 */
--color-secondary: 255 183 77; /* #ffb74d - Light Orange */
--color-accent: 255 213 79; /* #ffd54f - Light Amber */
--color-background: 33 18 18; /* #211212 */
--color-surface: 56 28 28; /* #381c1c */
--color-surface-hover: 76 38 38; /* #4c2626 */
--color-text: 255 235 225; /* #ffebe1 */
--color-text-muted: 188 156 145; /* #bc9c91 */
--color-border: 97 48 35; /* #613023 */
--color-shadow: 0 0 0; /* #000000 */
}
/* Monochrome Theme - Light Mode */
[data-theme="monochrome"] {
--color-primary: 33 33 33; /* #212121 - Gray 900 */
--color-primary-hover: 0 0 0; /* #000000 */
--color-secondary: 97 97 97; /* #616161 - Gray 700 */
--color-accent: 66 66 66; /* #424242 - Gray 800 */
--color-background: 255 255 255; /* #ffffff */
--color-surface: 250 250 250; /* #fafafa - Gray 50 */
--color-surface-hover: 245 245 245; /* #f5f5f5 - Gray 100 */
--color-text: 33 33 33; /* #212121 */
--color-text-muted: 117 117 117; /* #757575 - Gray 600 */
--color-border: 224 224 224; /* #e0e0e0 - Gray 300 */
--color-shadow: 66 66 66; /* #424242 */
}
/* Monochrome Theme - Dark Mode */
[data-theme="monochrome"].dark {
--color-primary: 250 250 250; /* #fafafa - Gray 50 */
--color-primary-hover: 255 255 255; /* #ffffff */
--color-secondary: 189 189 189; /* #bdbdbd - Gray 400 */
--color-accent: 224 224 224; /* #e0e0e0 - Gray 300 */
--color-background: 18 18 18; /* #121212 */
--color-surface: 33 33 33; /* #212121 - Gray 900 */
--color-surface-hover: 48 48 48; /* #303030 */
--color-text: 250 250 250; /* #fafafa */
--color-text-muted: 158 158 158; /* #9e9e9e - Gray 500 */
--color-border: 66 66 66; /* #424242 - Gray 800 */
--color-shadow: 0 0 0; /* #000000 */
}
/* Default to Ocean Light if no theme is set */
:root {
--color-primary: 0 102 204;
--color-primary-hover: 0 82 163;
--color-secondary: 0 168 232;
--color-accent: 0 199 255;
--color-background: 255 255 255;
--color-surface: 240 248 255;
--color-surface-hover: 225 240 255;
--color-text: 26 26 26;
--color-text-muted: 102 102 102;
--color-border: 200 220 240;
--color-shadow: 0 51 102;
}
/* Smooth transitions for theme changes */
* {
@apply transition-colors duration-200;
}
}
/* Custom utility classes for theme colors */
@layer utilities {
.bg-theme-gradient {
@apply bg-gradient-to-br from-theme-primary to-theme-secondary;
}
.text-gradient {
@apply bg-gradient-to-r from-theme-primary to-theme-accent bg-clip-text text-transparent;
}
.glass {
@apply bg-theme-surface/80 backdrop-blur-md border border-theme-border;
}
.glass-dark {
@apply bg-theme-surface/60 backdrop-blur-lg border border-theme-border;
}
}

View file

@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
darkMode: 'class', // Enables class-based dark mode
theme: {
extend: {
colors: {
// Theme colors will use CSS variables for dynamic theming
'theme': {
'primary': 'rgb(var(--color-primary) / <alpha-value>)',
'primary-hover': 'rgb(var(--color-primary-hover) / <alpha-value>)',
'secondary': 'rgb(var(--color-secondary) / <alpha-value>)',
'accent': 'rgb(var(--color-accent) / <alpha-value>)',
'background': 'rgb(var(--color-background) / <alpha-value>)',
'surface': 'rgb(var(--color-surface) / <alpha-value>)',
'surface-hover': 'rgb(var(--color-surface-hover) / <alpha-value>)',
'text': 'rgb(var(--color-text) / <alpha-value>)',
'text-muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
'border': 'rgb(var(--color-border) / <alpha-value>)',
}
},
backgroundColor: {
'base': 'rgb(var(--color-background) / <alpha-value>)',
'surface': 'rgb(var(--color-surface) / <alpha-value>)',
},
textColor: {
'base': 'rgb(var(--color-text) / <alpha-value>)',
'muted': 'rgb(var(--color-text-muted) / <alpha-value>)',
},
borderColor: {
'base': 'rgb(var(--color-border) / <alpha-value>)',
},
ringColor: {
'primary': 'rgb(var(--color-primary) / <alpha-value>)',
},
fontFamily: {
'sans': ['system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
'mono': ['SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'monospace'],
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-soft': 'pulseSoft 2s infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
pulseSoft: {
'0%, 100%': { opacity: '1' },
'50%': { opacity: '0.5' },
},
},
boxShadow: {
'theme-sm': '0 1px 2px 0 rgb(var(--color-shadow) / 0.05)',
'theme-md': '0 4px 6px -1px rgb(var(--color-shadow) / 0.1)',
'theme-lg': '0 10px 15px -3px rgb(var(--color-shadow) / 0.1)',
'theme-xl': '0 20px 25px -5px rgb(var(--color-shadow) / 0.1)',
},
},
},
plugins: [],
}

View file

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

View file

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

View file

@ -0,0 +1,168 @@
import { View, Text, StyleSheet, ScrollView, Pressable } from 'react-native';
import { Link } from 'expo-router';
import { useJobStore } from '@/stores/jobs';
export default function HomeScreen() {
const { jobs, activeJobs } = useJobStore();
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>
<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>
{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,
},
});

View file

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

View file

@ -0,0 +1,222 @@
import { useState } from 'react';
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 addJob = useJobStore((state) => state.addJob);
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;
}
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>
<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>
<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',
},
});

View file

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

View file

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

View file

@ -0,0 +1,16 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: [
[
'module-resolver',
{
alias: {
'@': './src',
},
},
],
],
};
};

View file

@ -0,0 +1,38 @@
{
"name": "@transcriber/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"
}
}

View file

@ -0,0 +1,76 @@
import Constants from 'expo-constants';
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;
}
export interface CreateJobRequest {
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,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || 'Request failed');
}
return res.json();
}
export const api = {
createJob: (data: CreateJobRequest) =>
request<TranscriptionJob>('/transcription', {
method: 'POST',
body: JSON.stringify(data),
}),
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
cancelJob: (id: string) =>
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
health: () => request<{ status: string }>('/health'),
};

View file

@ -0,0 +1,71 @@
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;
}
export const useJobStore = create<JobStore>((set, get) => ({
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'
),
};
}),
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'
),
};
}),
setJobs: (jobs) =>
set({
jobs,
activeJobs: jobs.filter(
(j) =>
j.status === 'pending' ||
j.status === 'downloading' ||
j.status === 'transcribing'
),
}),
}));

View file

@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

View file

@ -0,0 +1,2 @@
# API URL
PUBLIC_API_URL=http://localhost:3006

View file

@ -0,0 +1,28 @@
{
"name": "@transcriber/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"
}

View file

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

View file

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: theme('colors.primary.600');
--color-primary-hover: theme('colors.primary.700');
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
font-family: system-ui, -apple-system, sans-serif;
}

13
apps/transcriber/apps/web/src/app.d.ts vendored Normal file
View file

@ -0,0 +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 {}
}
}
export {};

View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

View file

@ -0,0 +1,107 @@
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;
}
export interface CreateJobRequest {
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;
}
export interface Stats {
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,
},
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || 'Request failed');
}
return res.json();
}
export const api = {
// Transcription
createJob: (data: CreateJobRequest) =>
request<TranscriptionJob>('/transcription', {
method: 'POST',
body: JSON.stringify(data),
}),
getJob: (id: string) => request<TranscriptionJob>(`/transcription/${id}`),
getAllJobs: () => request<TranscriptionJob[]>('/transcription'),
cancelJob: (id: string) =>
request<TranscriptionJob>(`/transcription/${id}`, { method: 'DELETE' }),
getStats: () => request<Stats>('/transcription/stats'),
// Playlists
getPlaylists: () => request<Playlist[]>('/playlist'),
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),
}),
// Whisper
getModels: () =>
request<{
models: { name: string; size: string; speed: string; accuracy: string }[];
defaultProvider: string;
openaiAvailable: boolean;
}>('/whisper/models'),
// Health
health: () => request<{ status: string }>('/health'),
};

View file

@ -0,0 +1,103 @@
import { writable, derived, type Writable } from 'svelte/store';
import { browser } from '$app/environment';
import type { TranscriptionJob } from '$lib/api/client';
import { PUBLIC_API_URL } from '$env/static/public';
const WS_URL = (PUBLIC_API_URL || 'http://localhost:3006').replace('http', 'ws');
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()
)
);
export const activeJobs = derived(jobList, ($jobs) =>
$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;
const connect = () => {
socket = new WebSocket(`${WS_URL}/progress`);
socket.onopen = () => {
console.log('[WebSocket] Connected');
isConnected.set(true);
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
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);
}
};
socket.onclose = () => {
console.log('[WebSocket] Disconnected');
isConnected.set(false);
// Reconnect after 3 seconds
reconnectTimeout = setTimeout(connect, 3000);
};
socket.onerror = (error) => {
console.error('[WebSocket] Error:', error);
};
};
connect();
}
export function addJob(job: TranscriptionJob) {
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);
});
}
export function cleanup() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
if (socket) {
socket.close();
}
}

View file

@ -0,0 +1,47 @@
<script lang="ts">
import '../app.css';
import { onMount, onDestroy } from 'svelte';
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
onMount(() => {
initWebSocket();
});
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>
<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>
</div>

View file

@ -0,0 +1,100 @@
<script lang="ts">
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);
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>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 py-8">
<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}
<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
{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}
</div>

View file

@ -0,0 +1,64 @@
<script lang="ts">
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);
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;
});
</script>
<svelte:head>
<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>
{#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>

View file

@ -0,0 +1,142 @@
<script lang="ts">
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);
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)' },
];
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;
}
}
</script>
<svelte:head>
<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>
{#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>
<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>
{#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>
</div>

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte';
import { api } from '$lib/api/client';
import { jobList } from '$lib/stores/jobs';
let loading = $state(true);
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'));
</script>
<svelte:head>
<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>
{#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}
</div>

View file

@ -0,0 +1,12 @@
import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter()
}
};
export default config;

View file

@ -0,0 +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: [],
};

View file

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

View file

@ -0,0 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()]
});

View file

@ -0,0 +1,372 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>YouTube Transcriber - Admin Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #0f172a 0%, #1e293b 100%);
color: #e2e8f0;
min-height: 100vh;
}
.header {
background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(10px);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
padding: 1.5rem;
}
.header h1 {
font-size: 1.5rem;
font-weight: 600;
color: #60a5fa;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 2rem;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.stat-card {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 1.5rem;
transition: transform 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
}
.stat-value {
font-size: 2.5rem;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #a78bfa);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
margin-bottom: 0.5rem;
}
.stat-label {
color: #94a3b8;
font-size: 0.875rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.quick-action {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 2rem;
margin-bottom: 2rem;
}
.quick-action h2 {
margin-bottom: 1rem;
color: #cbd5e1;
}
.input-group {
display: flex;
gap: 1rem;
}
input {
flex: 1;
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 1rem;
}
input:focus {
outline: none;
border-color: #60a5fa;
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
}
select {
padding: 0.75rem 1rem;
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(148, 163, 184, 0.2);
border-radius: 8px;
color: #e2e8f0;
font-size: 1rem;
cursor: pointer;
}
button {
padding: 0.75rem 2rem;
background: linear-gradient(135deg, #3b82f6, #8b5cf6);
border: none;
border-radius: 8px;
color: white;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
button:hover {
transform: translateY(-1px);
box-shadow: 0 10px 20px rgba(59, 130, 246, 0.3);
}
button:active {
transform: translateY(0);
}
.jobs-list {
background: rgba(30, 41, 59, 0.5);
backdrop-filter: blur(10px);
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 12px;
padding: 2rem;
}
.job-item {
background: rgba(15, 23, 42, 0.4);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.job-status {
padding: 0.25rem 0.75rem;
border-radius: 9999px;
font-size: 0.875rem;
font-weight: 600;
}
.status-completed {
background: rgba(34, 197, 94, 0.2);
color: #4ade80;
}
.status-processing {
background: rgba(251, 191, 36, 0.2);
color: #fbbf24;
}
.status-failed {
background: rgba(239, 68, 68, 0.2);
color: #f87171;
}
.loader {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(96, 165, 250, 0.3);
border-radius: 50%;
border-top-color: #60a5fa;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.nav-links {
display: flex;
gap: 2rem;
margin-top: 1rem;
}
.nav-links a {
color: #94a3b8;
text-decoration: none;
transition: color 0.2s;
}
.nav-links a:hover {
color: #60a5fa;
}
</style>
</head>
<body>
<div class="header">
<div class="container">
<h1>🎥 YouTube Transcriber - Admin Dashboard</h1>
<div class="nav-links">
<a href="http://localhost:4321">→ Public Website</a>
<a href="http://localhost:8000/docs">→ API Docs</a>
</div>
</div>
</div>
<div class="container">
<div class="stats-grid">
<div class="stat-card">
<div class="stat-value" id="total-transcripts">-</div>
<div class="stat-label">Transkripte</div>
</div>
<div class="stat-card">
<div class="stat-value" id="active-jobs">-</div>
<div class="stat-label">Aktive Jobs</div>
</div>
<div class="stat-card">
<div class="stat-value" id="total-size">-</div>
<div class="stat-label">Speicher (MB)</div>
</div>
<div class="stat-card">
<div class="stat-value" id="playlists-count">-</div>
<div class="stat-label">Playlists</div>
</div>
</div>
<div class="quick-action">
<h2>🚀 Neue Transkription starten</h2>
<div class="input-group">
<input type="text" id="url-input" placeholder="YouTube URL eingeben...">
<select id="model-select">
<option value="tiny">Tiny (Schnell)</option>
<option value="base" selected>Base</option>
<option value="small">Small</option>
<option value="medium">Medium</option>
<option value="large">Large (Beste Qualität)</option>
</select>
<select id="language-select">
<option value="de" selected>Deutsch</option>
<option value="en">English</option>
</select>
<button onclick="startTranscription()">Transkribieren</button>
</div>
</div>
<div class="jobs-list">
<h2 style="margin-bottom: 1rem;">📋 Aktuelle Jobs</h2>
<div id="jobs-container">
<div class="job-item">
<span style="color: #94a3b8;">Keine aktiven Jobs</span>
</div>
</div>
</div>
</div>
<script>
const API_URL = 'http://localhost:8000';
async function loadStats() {
try {
const response = await fetch(`${API_URL}/api/stats`);
const data = await response.json();
document.getElementById('total-transcripts').textContent = data.total_transcripts || '0';
document.getElementById('active-jobs').textContent = data.active_jobs || '0';
document.getElementById('total-size').textContent = data.total_size_mb?.toFixed(1) || '0';
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function loadPlaylists() {
try {
const response = await fetch(`${API_URL}/api/playlists`);
const data = await response.json();
document.getElementById('playlists-count').textContent = data.length || '0';
} catch (error) {
console.error('Error loading playlists:', error);
}
}
async function loadJobs() {
try {
const response = await fetch(`${API_URL}/api/jobs`);
const jobs = await response.json();
const container = document.getElementById('jobs-container');
if (jobs.length === 0) {
container.innerHTML = '<div class="job-item"><span style="color: #94a3b8;">Keine aktiven Jobs</span></div>';
} else {
container.innerHTML = jobs.map(job => `
<div class="job-item">
<div>
<div style="font-weight: 600; margin-bottom: 0.25rem;">${job.url}</div>
<div style="color: #94a3b8; font-size: 0.875rem;">
${new Date(job.created_at).toLocaleString('de-DE')}
</div>
</div>
<span class="job-status status-${job.status}">
${job.status === 'transcribing' ? '<span class="loader"></span>' : ''}
${job.status.toUpperCase()}
</span>
</div>
`).join('');
}
} catch (error) {
console.error('Error loading jobs:', error);
}
}
async function startTranscription() {
const url = document.getElementById('url-input').value;
const model = document.getElementById('model-select').value;
const language = document.getElementById('language-select').value;
if (!url) {
alert('Bitte YouTube URL eingeben');
return;
}
try {
const response = await fetch(`${API_URL}/api/transcribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url, model, language })
});
if (response.ok) {
document.getElementById('url-input').value = '';
alert('Transkription gestartet!');
loadStats();
loadJobs();
} else {
alert('Fehler beim Starten der Transkription');
}
} catch (error) {
console.error('Error starting transcription:', error);
alert('Fehler: API nicht erreichbar');
}
}
// Initial load
loadStats();
loadPlaylists();
loadJobs();
// Refresh every 5 seconds
setInterval(() => {
loadStats();
loadJobs();
}, 5000);
</script>
</body>
</html>

View file

@ -0,0 +1,372 @@
#!/usr/bin/env python3
"""
FastAPI Server für YouTube Transcriber Web Interface
"""
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from pydantic import BaseModel, HttpUrl
from typing import List, Optional, Dict, Any
import asyncio
import json
import os
from pathlib import Path
from datetime import datetime
import uuid
from enum import Enum
# Import existing transcriber modules
from transcriber_v4_parallel import ParallelTranscriber
import whisper
app = FastAPI(title="YouTube Transcriber API", version="1.0.0")
# CORS middleware for Astro frontend
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:4321", "http://localhost:3000"], # Astro dev server
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Global state
class JobStatus(str, Enum):
PENDING = "pending"
DOWNLOADING = "downloading"
TRANSCRIBING = "transcribing"
COMPLETED = "completed"
FAILED = "failed"
class TranscriptionJob:
def __init__(self, job_id: str, url: str, model: str = "base", language: str = "de"):
self.id = job_id
self.url = url
self.model = model
self.language = language
self.status = JobStatus.PENDING
self.progress = 0
self.created_at = datetime.now()
self.completed_at = None
self.transcript_path = None
self.error = None
self.video_info = {}
# Store active jobs
active_jobs: Dict[str, TranscriptionJob] = {}
websocket_connections: List[WebSocket] = []
# Request/Response models
class TranscribeRequest(BaseModel):
url: HttpUrl
model: str = "base"
language: str = "de"
class PlaylistRequest(BaseModel):
name: str
description: Optional[str] = None
urls: List[HttpUrl]
class JobResponse(BaseModel):
id: str
url: str
status: str
progress: int
created_at: datetime
completed_at: Optional[datetime]
transcript_path: Optional[str]
error: Optional[str]
video_info: Dict[str, Any]
# WebSocket manager
class ConnectionManager:
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except:
pass
manager = ConnectionManager()
# API Endpoints
@app.get("/")
async def root():
return {"message": "YouTube Transcriber API", "version": "1.0.0"}
@app.post("/api/transcribe", response_model=JobResponse)
async def start_transcription(request: TranscribeRequest, background_tasks: BackgroundTasks):
"""Start a new transcription job"""
job_id = str(uuid.uuid4())
job = TranscriptionJob(job_id, str(request.url), request.model, request.language)
active_jobs[job_id] = job
# Start transcription in background
background_tasks.add_task(process_transcription, job)
return JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
@app.get("/api/status/{job_id}", response_model=JobResponse)
async def get_job_status(job_id: str):
"""Get status of a transcription job"""
if job_id not in active_jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = active_jobs[job_id]
return JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
@app.get("/api/jobs")
async def list_jobs():
"""List all transcription jobs"""
return [
JobResponse(
id=job.id,
url=job.url,
status=job.status,
progress=job.progress,
created_at=job.created_at,
completed_at=job.completed_at,
transcript_path=job.transcript_path,
error=job.error,
video_info=job.video_info
)
for job in active_jobs.values()
]
@app.get("/api/transcripts")
async def list_transcripts():
"""List all available transcripts"""
transcript_dir = Path("transcripts")
transcripts = []
if transcript_dir.exists():
for playlist_dir in transcript_dir.iterdir():
if playlist_dir.is_dir():
for channel_dir in playlist_dir.iterdir():
if channel_dir.is_dir():
for transcript_file in channel_dir.glob("*.txt"):
transcripts.append({
"playlist": playlist_dir.name,
"channel": channel_dir.name,
"filename": transcript_file.name,
"path": str(transcript_file),
"size": transcript_file.stat().st_size,
"modified": datetime.fromtimestamp(transcript_file.stat().st_mtime)
})
return transcripts
@app.get("/api/transcript/{transcript_path:path}")
async def get_transcript(transcript_path: str):
"""Get transcript content"""
file_path = Path(transcript_path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="Transcript not found")
return FileResponse(file_path)
@app.get("/api/playlists")
async def list_playlists():
"""List all playlists"""
playlist_dir = Path("playlists")
playlists = []
if playlist_dir.exists():
for category_dir in playlist_dir.iterdir():
if category_dir.is_dir():
for playlist_file in category_dir.glob("*.txt"):
urls = []
with open(playlist_file, 'r') as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
urls.append(line)
playlists.append({
"category": category_dir.name,
"name": playlist_file.stem,
"path": str(playlist_file),
"url_count": len(urls),
"urls": urls
})
return playlists
@app.post("/api/playlists")
async def create_playlist(request: PlaylistRequest):
"""Create a new playlist"""
# Extract category and name from the playlist name (e.g., "tech/python_tutorials")
parts = request.name.split('/')
if len(parts) == 2:
category, name = parts
else:
category = "general"
name = request.name
playlist_dir = Path("playlists") / category
playlist_dir.mkdir(parents=True, exist_ok=True)
playlist_file = playlist_dir / f"{name}.txt"
with open(playlist_file, 'w') as f:
if request.description:
f.write(f"# {request.description}\n")
f.write("# Eine URL pro Zeile\n\n")
for url in request.urls:
f.write(f"{url}\n")
return {"message": "Playlist created", "path": str(playlist_file)}
@app.delete("/api/jobs/{job_id}")
async def cancel_job(job_id: str):
"""Cancel a transcription job"""
if job_id not in active_jobs:
raise HTTPException(status_code=404, detail="Job not found")
job = active_jobs[job_id]
job.status = JobStatus.FAILED
job.error = "Cancelled by user"
await manager.broadcast({
"type": "job_cancelled",
"job_id": job_id
})
return {"message": "Job cancelled"}
@app.websocket("/ws/progress")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket for real-time progress updates"""
await manager.connect(websocket)
try:
while True:
# Keep connection alive
await asyncio.sleep(1)
# Send heartbeat
await websocket.send_json({"type": "heartbeat"})
except WebSocketDisconnect:
manager.disconnect(websocket)
# Background task for processing
async def process_transcription(job: TranscriptionJob):
"""Process a transcription job"""
try:
# Update status
job.status = JobStatus.DOWNLOADING
await manager.broadcast({
"type": "job_update",
"job_id": job.id,
"status": job.status,
"progress": 10
})
# Initialize transcriber
transcriber = ParallelTranscriber(
model_size=job.model,
language=job.language,
max_downloads=1, # Single job
max_transcriptions=1
)
# Simulate processing (replace with actual transcriber call)
job.status = JobStatus.TRANSCRIBING
job.progress = 50
await manager.broadcast({
"type": "job_update",
"job_id": job.id,
"status": job.status,
"progress": job.progress
})
# TODO: Integrate actual transcription
# result = await transcriber.process_single(job.url)
# Mark as completed
job.status = JobStatus.COMPLETED
job.progress = 100
job.completed_at = datetime.now()
await manager.broadcast({
"type": "job_complete",
"job_id": job.id,
"status": job.status,
"progress": job.progress
})
except Exception as e:
job.status = JobStatus.FAILED
job.error = str(e)
await manager.broadcast({
"type": "job_error",
"job_id": job.id,
"error": job.error
})
@app.get("/api/models")
async def get_available_models():
"""Get available Whisper models"""
return {
"models": [
{"name": "tiny", "size": "39 MB", "speed": "~10x", "accuracy": "75%"},
{"name": "base", "size": "74 MB", "speed": "~7x", "accuracy": "85%"},
{"name": "small", "size": "244 MB", "speed": "~4x", "accuracy": "91%"},
{"name": "medium", "size": "769 MB", "speed": "~2x", "accuracy": "94%"},
{"name": "large", "size": "1.5 GB", "speed": "~1x", "accuracy": "96-98%"}
]
}
@app.get("/api/stats")
async def get_statistics():
"""Get system statistics"""
transcript_dir = Path("transcripts")
total_transcripts = 0
total_size = 0
if transcript_dir.exists():
for file in transcript_dir.rglob("*.txt"):
total_transcripts += 1
total_size += file.stat().st_size
return {
"total_transcripts": total_transcripts,
"total_size_mb": round(total_size / 1024 / 1024, 2),
"active_jobs": len([j for j in active_jobs.values() if j.status in [JobStatus.PENDING, JobStatus.DOWNLOADING, JobStatus.TRANSCRIBING]]),
"completed_jobs": len([j for j in active_jobs.values() if j.status == JobStatus.COMPLETED]),
"failed_jobs": len([j for j in active_jobs.values() if j.status == JobStatus.FAILED])
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000, reload=True)

View file

@ -0,0 +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%"
}
}
}

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