mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
feat(wisekeep): migrate from archive to local-first + Hono architecture
- Move from apps-archived/ to apps/ - Delete NestJS backend, mobile app, legacy Python, shared-types - Create Hono/Bun server with Groq Whisper transcription via yt-dlp - Create local-first store (transcripts, playlists) with guest seed - Rewrite web app: Transcribe page, Library with search/expand, Playlists CRUD, auth via shared-auth-ui, AuthGate with guest mode - Remove broken landing page subpages (Prettier-incompatible Astro) - Add wisekeep to root CLAUDE.md and dev scripts - Fix duplicate wisekeep entries in shared-branding - 0 type errors on both server and web Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f06c98709a
commit
d7b4042164
151 changed files with 1490 additions and 11980 deletions
|
|
@ -57,6 +57,7 @@ For comprehensive guidelines on code patterns and conventions, see the `.claude/
|
|||
| **taktik** | Time tracking | Web |
|
||||
| **uload** | URL shortener & link management | Server, Web, Landing |
|
||||
| **news** | AI news reader & personal library | Server, Web, Landing |
|
||||
| **wisekeep** | AI transcription & wisdom library | Server, Web, Landing |
|
||||
| **calc** | Calculator & converter | Web |
|
||||
| **playground** | LLM playground | Web |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,233 +0,0 @@
|
|||
# CLAUDE.md - Wisekeep
|
||||
|
||||
This file provides guidance to Claude Code when working with the Wisekeep project.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Wisekeep is an AI-powered wisdom extraction application that captures insights from video content:
|
||||
|
||||
- YouTube video download via yt-dlp
|
||||
- Ultra-fast audio transcription using Groq Whisper API (~300x realtime)
|
||||
- Fallback to local Whisper for offline use
|
||||
- Playlist management for batch processing
|
||||
- Real-time progress updates via WebSocket
|
||||
- Multi-platform support (Web, Mobile, Landing)
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
apps/wisekeep/
|
||||
├── 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 wisekeep apps
|
||||
pnpm wisekeep:dev
|
||||
|
||||
# Start individual apps
|
||||
pnpm dev:wisekeep:backend # NestJS backend (port 3006)
|
||||
pnpm dev:wisekeep:web # SvelteKit web (port 5173)
|
||||
pnpm dev:wisekeep:landing # Astro landing (port 4321)
|
||||
pnpm dev:wisekeep:mobile # Expo mobile
|
||||
|
||||
# Start web + backend together
|
||||
pnpm dev:wisekeep:app
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Create `apps/wisekeep/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
|
||||
```
|
||||
|
|
@ -1,392 +0,0 @@
|
|||
# 🎥 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**
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
# 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
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
{
|
||||
"name": "@wisekeep/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep Backend - NestJS API for wisdom extraction",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/platform-socket.io": "^10.4.15",
|
||||
"@nestjs/websockets": "^10.4.15",
|
||||
"@types/socket.io-client": "^3.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"openai": "^4.73.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@nestjs/testing": "^10.4.15",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.17.0",
|
||||
"@typescript-eslint/parser": "^8.17.0",
|
||||
"eslint": "^9.16.0",
|
||||
"jest": "^29.7.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"ts-jest": "^29.2.5",
|
||||
"ts-loader": "^9.5.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
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();
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,173 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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;
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,79 +0,0 @@
|
|||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { Module, Global } from '@nestjs/common';
|
||||
import { ProgressGateway } from './progress.gateway';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [ProgressGateway],
|
||||
exports: [ProgressGateway],
|
||||
})
|
||||
export class WebsocketModule {}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
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 {}
|
||||
|
|
@ -1,219 +0,0 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { YoutubeService } from './youtube.service';
|
||||
|
||||
@Module({
|
||||
providers: [YoutubeService],
|
||||
exports: [YoutubeService],
|
||||
})
|
||||
export class YoutubeModule {}
|
||||
|
|
@ -1,163 +0,0 @@
|
|||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,26 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,263 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,257 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,235 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,109 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,189 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,289 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,650 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,488 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,658 +0,0 @@
|
|||
---
|
||||
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"></path>
|
||||
</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>
|
||||
|
|
@ -1,286 +0,0 @@
|
|||
---
|
||||
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>
|
||||
|
|
@ -1,362 +0,0 @@
|
|||
---
|
||||
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"></path>
|
||||
</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>
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/** @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: [],
|
||||
}
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
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>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
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',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,190 +0,0 @@
|
|||
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',
|
||||
},
|
||||
});
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
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,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: [
|
||||
[
|
||||
'module-resolver',
|
||||
{
|
||||
alias: {
|
||||
'@': './src',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
};
|
||||
};
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
{
|
||||
"name": "@wisekeep/mobile",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"dev": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"build": "expo export",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^14.0.0",
|
||||
"expo": "~52.0.0",
|
||||
"expo-clipboard": "~7.0.0",
|
||||
"expo-constants": "~17.0.0",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
"expo-status-bar": "~2.0.0",
|
||||
"nativewind": "^4.1.0",
|
||||
"react": "18.3.1",
|
||||
"react-native": "0.76.1",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.1.0",
|
||||
"zustand": "^5.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@types/react": "~18.3.0",
|
||||
"babel-plugin-module-resolver": "^5.0.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "~5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,66 +0,0 @@
|
|||
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'),
|
||||
};
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
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'
|
||||
),
|
||||
}),
|
||||
}));
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
svelteConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', '.svelte-kit/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...svelteConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
{
|
||||
"name": "@wisekeep/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"type-check": "svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.3.1",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"svelte": "^5.12.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
import { env } from '$env/dynamic/public';
|
||||
|
||||
const API_BASE = env.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'),
|
||||
};
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
/**
|
||||
* Feedback Service Instance for Wisekeep Web App
|
||||
*/
|
||||
|
||||
import { createFeedbackService } from '@manacore/shared-feedback-service';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { PUBLIC_MANA_CORE_AUTH_URL } from '$env/static/public';
|
||||
|
||||
const MANA_AUTH_URL = PUBLIC_MANA_CORE_AUTH_URL || 'http://localhost:3001';
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: MANA_AUTH_URL,
|
||||
appId: 'wisekeep',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { AppSlider } from '@manacore/shared-ui';
|
||||
import type { AppItem } from '@manacore/shared-ui';
|
||||
import { MANA_APPS, APP_STATUS_LABELS, APP_SLIDER_LABELS } from '@manacore/shared-branding';
|
||||
|
||||
// Convert MANA_APPS to AppItem format (German)
|
||||
const apps: AppItem[] = MANA_APPS.map((app) => ({
|
||||
name: app.name,
|
||||
description: app.description.de,
|
||||
longDescription: app.longDescription.de,
|
||||
icon: app.icon,
|
||||
color: app.color,
|
||||
comingSoon: app.comingSoon,
|
||||
status: app.status,
|
||||
}));
|
||||
|
||||
const statusLabels = APP_STATUS_LABELS.de;
|
||||
const labels = APP_SLIDER_LABELS.de;
|
||||
|
||||
function handleAppClick(app: AppItem, index: number) {
|
||||
console.log('Opening app:', app.name);
|
||||
}
|
||||
</script>
|
||||
|
||||
<AppSlider
|
||||
{apps}
|
||||
title={labels.title}
|
||||
isDark={false}
|
||||
{statusLabels}
|
||||
comingSoonLabel={labels.comingSoon}
|
||||
openAppLabel={labels.openApp}
|
||||
onAppClick={handleAppClick}
|
||||
/>
|
||||
|
|
@ -1,205 +0,0 @@
|
|||
/**
|
||||
* Auth Store - Manages authentication state using Svelte 5 runes
|
||||
* Using Mana Core Auth
|
||||
*/
|
||||
|
||||
import { browser } from '$app/environment';
|
||||
import { initializeWebAuth } from '@manacore/shared-auth';
|
||||
import type { UserData } from '@manacore/shared-auth';
|
||||
|
||||
// Initialize Mana Core Auth only on the client side
|
||||
const MANA_AUTH_URL = 'http://localhost:3001';
|
||||
|
||||
// Lazy initialization to avoid SSR issues with localStorage
|
||||
let _authService: ReturnType<typeof initializeWebAuth>['authService'] | null = null;
|
||||
let _tokenManager: ReturnType<typeof initializeWebAuth>['tokenManager'] | null = null;
|
||||
|
||||
function getAuthService() {
|
||||
if (!browser) return null;
|
||||
if (!_authService) {
|
||||
const auth = initializeWebAuth({ baseUrl: MANA_AUTH_URL });
|
||||
_authService = auth.authService;
|
||||
_tokenManager = auth.tokenManager;
|
||||
}
|
||||
return _authService;
|
||||
}
|
||||
|
||||
// State
|
||||
let user = $state<UserData | null>(null);
|
||||
let loading = $state(true);
|
||||
let initialized = $state(false);
|
||||
|
||||
export const authStore = {
|
||||
// Getters
|
||||
get user() {
|
||||
return user;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get isAuthenticated() {
|
||||
return !!user;
|
||||
},
|
||||
get initialized() {
|
||||
return initialized;
|
||||
},
|
||||
|
||||
/**
|
||||
* Initialize auth state from stored tokens
|
||||
*/
|
||||
async initialize() {
|
||||
if (initialized) return;
|
||||
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
initialized = true;
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
try {
|
||||
const authenticated = await authService.isAuthenticated();
|
||||
if (authenticated) {
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
}
|
||||
initialized = true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize auth:', error);
|
||||
user = null;
|
||||
initialized = true;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
async signIn(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signIn(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Login failed' };
|
||||
}
|
||||
|
||||
// Get user data from token
|
||||
const userData = await authService.getUserFromToken();
|
||||
user = userData;
|
||||
|
||||
return { success: true, error: null };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign up with email and password
|
||||
*/
|
||||
async signUp(email: string, password: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server', needsVerification: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.signUp(email, password);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Signup failed', needsVerification: false };
|
||||
}
|
||||
|
||||
// Mana Core Auth requires separate login after signup
|
||||
if (result.needsVerification) {
|
||||
return { success: true, error: null, needsVerification: true };
|
||||
}
|
||||
|
||||
// Auto sign in after successful signup
|
||||
const signInResult = await this.signIn(email, password);
|
||||
return { ...signInResult, needsVerification: false };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage, needsVerification: false };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign out
|
||||
*/
|
||||
async signOut() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await authService.signOut();
|
||||
user = null;
|
||||
} catch (error) {
|
||||
console.error('Sign out error:', error);
|
||||
// Clear user even if sign out fails
|
||||
user = null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Send password reset email
|
||||
*/
|
||||
async resetPassword(email: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return { success: false, error: 'Auth not available on server' };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await authService.forgotPassword(email);
|
||||
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error || 'Password reset failed' };
|
||||
}
|
||||
|
||||
return { success: true, error: null };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get user credit balance
|
||||
*/
|
||||
async getCredits() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const credits = await authService.getUserCredits();
|
||||
return credits;
|
||||
} catch (error) {
|
||||
console.error('Failed to get credits:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get access token for API calls
|
||||
*/
|
||||
async getAccessToken() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) {
|
||||
return null;
|
||||
}
|
||||
return await authService.getAppToken();
|
||||
},
|
||||
};
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
import { writable, derived } from 'svelte/store';
|
||||
import type { Writable } from 'svelte/store';
|
||||
import { browser } from '$app/environment';
|
||||
import type { TranscriptionJob } from '$lib/api/client';
|
||||
|
||||
const API_URL = 'http://localhost:3006';
|
||||
const WS_URL = API_URL.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();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { ManaCoreLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
|
||||
// German translations
|
||||
const translations = {
|
||||
title: 'Passwort vergessen',
|
||||
subtitle: 'Gib deine E-Mail ein, um einen Reset-Link zu erhalten',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
resetButton: 'Reset-Link senden',
|
||||
sending: 'Wird gesendet...',
|
||||
success: 'E-Mail gesendet!',
|
||||
backToLogin: 'Zurück zur Anmeldung',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
resetFailed: 'Zurücksetzen fehlgeschlagen',
|
||||
resetSuccess: 'Bitte überprüfe deine E-Mails',
|
||||
};
|
||||
|
||||
async function handleResetPassword(email: string) {
|
||||
return authStore.resetPassword(email);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Passwort vergessen | Wisekeep</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage
|
||||
appName="Wisekeep"
|
||||
logo={ManaCoreLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onResetPassword={handleResetPassword}
|
||||
{goto}
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</ForgotPasswordPage>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { ManaCoreLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
|
||||
// Get redirect URL from query params
|
||||
const redirectTo = $derived($page.url.searchParams.get('redirectTo') || '/dashboard');
|
||||
|
||||
// German translations
|
||||
const translations = {
|
||||
title: 'Anmelden',
|
||||
subtitle: 'Melde dich mit deinem Konto an',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
rememberMe: 'Angemeldet bleiben',
|
||||
forgotPassword: 'Passwort vergessen?',
|
||||
signInButton: 'Anmelden',
|
||||
signingIn: 'Wird angemeldet...',
|
||||
success: 'Erfolgreich!',
|
||||
orDivider: 'oder',
|
||||
noAccount: 'Noch kein Konto?',
|
||||
createAccount: 'Jetzt registrieren',
|
||||
skipToForm: 'Zum Login-Formular springen',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
passwordRequired: 'Passwort ist erforderlich',
|
||||
signInFailed: 'Anmeldung fehlgeschlagen',
|
||||
googleSignInFailed: 'Google-Anmeldung fehlgeschlagen',
|
||||
signInSuccess: 'Erfolgreich angemeldet. Weiterleitung...',
|
||||
googleSignInSuccess: 'Erfolgreich mit Google angemeldet. Weiterleitung...',
|
||||
};
|
||||
|
||||
async function handleSignIn(email: string, password: string) {
|
||||
return authStore.signIn(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Anmelden | Wisekeep</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
appName="Wisekeep"
|
||||
logo={ManaCoreLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignIn={handleSignIn}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect={redirectTo}
|
||||
registerPath="/register"
|
||||
forgotPasswordPath="/forgot-password"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</LoginPage>
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { ManaCoreLogo } from '@manacore/shared-branding';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import AppSlider from '$lib/components/AppSlider.svelte';
|
||||
|
||||
// German translations
|
||||
const translations = {
|
||||
title: 'Registrieren',
|
||||
subtitle: 'Erstelle dein Konto',
|
||||
emailPlaceholder: 'E-Mail',
|
||||
passwordPlaceholder: 'Passwort',
|
||||
confirmPasswordPlaceholder: 'Passwort wiederholen',
|
||||
signUpButton: 'Registrieren',
|
||||
signingUp: 'Wird registriert...',
|
||||
success: 'Erfolgreich!',
|
||||
orDivider: 'oder',
|
||||
hasAccount: 'Bereits ein Konto?',
|
||||
signIn: 'Jetzt anmelden',
|
||||
skipToForm: 'Zum Registrierungsformular springen',
|
||||
showPassword: 'Passwort anzeigen',
|
||||
hidePassword: 'Passwort verbergen',
|
||||
emailRequired: 'E-Mail ist erforderlich',
|
||||
emailInvalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
passwordRequired: 'Passwort ist erforderlich',
|
||||
passwordMinLength: 'Passwort muss mindestens 8 Zeichen haben',
|
||||
passwordsNotMatch: 'Passwörter stimmen nicht überein',
|
||||
signUpFailed: 'Registrierung fehlgeschlagen',
|
||||
signUpSuccess: 'Erfolgreich registriert. Weiterleitung...',
|
||||
verificationRequired: 'Bitte überprüfe deine E-Mails zur Bestätigung',
|
||||
};
|
||||
|
||||
async function handleSignUp(email: string, password: string) {
|
||||
return authStore.signUp(email, password);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Registrieren | Wisekeep</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage
|
||||
appName="Wisekeep"
|
||||
logo={ManaCoreLogo}
|
||||
primaryColor="#8b5cf6"
|
||||
onSignUp={handleSignUp}
|
||||
{goto}
|
||||
enableGoogle={false}
|
||||
enableApple={false}
|
||||
successRedirect="/"
|
||||
loginPath="/login"
|
||||
lightBackground="#f3e8ff"
|
||||
darkBackground="#1e1b4b"
|
||||
{translations}
|
||||
>
|
||||
{#snippet appSlider()}
|
||||
<AppSlider />
|
||||
{/snippet}
|
||||
</RegisterPage>
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
/**
|
||||
* Protected routes layout server
|
||||
* Auth checking is done client-side via Mana Core Auth
|
||||
*/
|
||||
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ url }) => {
|
||||
// Return the current path for client-side redirect logic
|
||||
return {
|
||||
pathname: url.pathname,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,182 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { initWebSocket, cleanup, isConnected } from '$lib/stores/jobs';
|
||||
import type { LayoutData } from './$types';
|
||||
import { PillNavigation } from '@manacore/shared-ui';
|
||||
import type { PillNavItem } from '@manacore/shared-ui';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
|
||||
let { children, data }: { children: any; data: LayoutData } = $props();
|
||||
|
||||
// App switcher items
|
||||
const appItems = getPillAppItems('wisekeep');
|
||||
|
||||
// User email for dropdown
|
||||
let userEmail = $derived(authStore.user?.email);
|
||||
|
||||
// Navigation items for Wisekeep
|
||||
const navItems: PillNavItem[] = [
|
||||
{ href: '/dashboard', label: 'Dashboard', icon: 'home' },
|
||||
{ href: '/transcribe', label: 'Transcribe', icon: 'mic' },
|
||||
{ href: '/transcripts', label: 'Transcripts', icon: 'document' },
|
||||
{ href: '/playlists', label: 'Playlists', icon: 'list' },
|
||||
{ href: '/settings', label: 'Settings', icon: 'settings' },
|
||||
];
|
||||
|
||||
let isChecking = $state(true);
|
||||
let isSidebarMode = $state(false);
|
||||
let isCollapsed = $state(false);
|
||||
let isDark = $state(false);
|
||||
|
||||
// Navigation shortcuts (Ctrl+1-5)
|
||||
const navRoutes = ['/dashboard', '/transcribe', '/transcripts', '/playlists', '/settings'];
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const target = event.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ((event.ctrlKey || event.metaKey) && !event.shiftKey && !event.altKey) {
|
||||
const num = parseInt(event.key);
|
||||
if (num >= 1 && num <= 5) {
|
||||
event.preventDefault();
|
||||
const route = navRoutes[num - 1];
|
||||
if (route) {
|
||||
goto(route);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleModeChange(isSidebar: boolean) {
|
||||
isSidebarMode = isSidebar;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('wisekeep-nav-sidebar', String(isSidebar));
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('wisekeep-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
isDark = !isDark;
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('wisekeep-dark-mode', String(isDark));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignOut() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
// Check auth on mount and redirect if not authenticated
|
||||
onMount(async () => {
|
||||
let shouldRedirect = false;
|
||||
|
||||
try {
|
||||
await authStore.initialize();
|
||||
shouldRedirect = !authStore.isAuthenticated;
|
||||
|
||||
if (!shouldRedirect) {
|
||||
// Initialize WebSocket after auth check
|
||||
initWebSocket();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Protected layout init error:', error);
|
||||
shouldRedirect = true;
|
||||
}
|
||||
|
||||
// Restore nav mode from localStorage
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
const savedSidebar = localStorage.getItem('wisekeep-nav-sidebar');
|
||||
if (savedSidebar === 'true') {
|
||||
isSidebarMode = true;
|
||||
}
|
||||
const savedCollapsed = localStorage.getItem('wisekeep-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
}
|
||||
const savedDark = localStorage.getItem('wisekeep-dark-mode');
|
||||
if (savedDark === 'true') {
|
||||
isDark = true;
|
||||
document.documentElement.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
// Always set isChecking to false
|
||||
isChecking = false;
|
||||
|
||||
if (shouldRedirect) {
|
||||
const redirectTo = encodeURIComponent(data.pathname || '/dashboard');
|
||||
goto(`/login?redirectTo=${redirectTo}`);
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
return () => cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
{#if isChecking}
|
||||
<div class="flex min-h-screen items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="mb-4 inline-block h-12 w-12 animate-spin rounded-full border-4 border-solid border-purple-600 border-r-transparent"
|
||||
></div>
|
||||
<p class="text-gray-600 dark:text-gray-400">Laden...</p>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Wisekeep"
|
||||
homeRoute="/dashboard"
|
||||
onLogout={handleSignOut}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isSidebarMode}
|
||||
onModeChange={handleModeChange}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
primaryColor="#9333ea"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/subscription"
|
||||
profileHref="/profile"
|
||||
allAppsHref="/apps"
|
||||
>
|
||||
{#snippet logo()}
|
||||
<span class="text-xl">🧠</span>
|
||||
<span class="pill-label font-bold">Wisekeep</span>
|
||||
{/snippet}
|
||||
</PillNavigation>
|
||||
|
||||
<main
|
||||
class="main-content flex-1 transition-all duration-300 {isCollapsed
|
||||
? ''
|
||||
: isSidebarMode
|
||||
? 'pl-[180px]'
|
||||
: 'pt-20'}"
|
||||
>
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { AppsPage } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Alle Apps - Wisekeep</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="apps-page-wrapper">
|
||||
<AppsPage currentAppId="wisekeep" locale="de" title="Alle Apps" />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.apps-page-wrapper {
|
||||
min-height: 100%;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import 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>Dashboard | Wisekeep</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-purple-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-purple-600 text-white rounded-lg hover:bg-purple-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-purple-600 h-2 rounded-full transition-all"
|
||||
style="width: {job.progress}%"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500 mt-1">{job.progress}%</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/shared-feedback-ui';
|
||||
import { feedbackService } from '$lib/api/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<FeedbackPage
|
||||
{feedbackService}
|
||||
appName="Wisekeep"
|
||||
currentUserId={authStore.user?.id}
|
||||
/>
|
||||
|
|
@ -1,65 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import 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 | Wisekeep</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>
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
<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 | Wisekeep</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-purple-500 focus:border-purple-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-purple-500 focus:border-purple-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-purple-500 focus:border-purple-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-purple-600 text-white rounded-lg hover:bg-purple-700 transition disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? 'Starting...' : 'Start Transcription'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<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 | Wisekeep</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-purple-600 text-white rounded-lg hover:bg-purple-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-purple-600 hover:text-purple-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>
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
{@render children()}
|
||||
</div>
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { onMount } from 'svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
onMount(async () => {
|
||||
await authStore.initialize();
|
||||
if (authStore.isAuthenticated) {
|
||||
goto('/dashboard', { replaceState: true });
|
||||
} else {
|
||||
goto('/login', { replaceState: true });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Wisekeep - AI Wisdom Extraction</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen">
|
||||
<div class="text-center">
|
||||
<div
|
||||
class="animate-spin w-10 h-10 border-4 border-purple-500 border-r-transparent rounded-full mx-auto"
|
||||
></div>
|
||||
<p class="mt-4 text-gray-600">Wird geladen...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
/** @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: [],
|
||||
};
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
<!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>
|
||||
|
|
@ -1,372 +0,0 @@
|
|||
#!/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)
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
{
|
||||
"default_model": "small",
|
||||
"default_language": "de",
|
||||
"models": {
|
||||
"tiny": {
|
||||
"size_mb": 39,
|
||||
"speed": "~10x Echtzeit",
|
||||
"accuracy": "75%"
|
||||
},
|
||||
"base": {
|
||||
"size_mb": 74,
|
||||
"speed": "~7x Echtzeit",
|
||||
"accuracy": "85%"
|
||||
},
|
||||
"small": {
|
||||
"size_mb": 244,
|
||||
"speed": "~4x Echtzeit",
|
||||
"accuracy": "91%"
|
||||
},
|
||||
"medium": {
|
||||
"size_mb": 769,
|
||||
"speed": "~2x Echtzeit",
|
||||
"accuracy": "94%"
|
||||
},
|
||||
"large": {
|
||||
"size_mb": 1550,
|
||||
"speed": "~1x Echtzeit",
|
||||
"accuracy": "96-98%"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
#!/bin/bash
|
||||
# YouTube Transcriber - Schnellauswahl
|
||||
|
||||
source venv/bin/activate
|
||||
|
||||
echo "🎥 YouTube Transcriber - Modell-Auswahl"
|
||||
echo "========================================"
|
||||
echo ""
|
||||
echo "1) 🚀 TINY - Schneller Test (39MB, ~10x Speed)"
|
||||
echo "2) 🎯 LARGE - Beste Qualität (1.5GB, ~1x Speed)"
|
||||
echo "3) 📋 SCAN - Alle Playlists scannen"
|
||||
echo "4) ⚡ PARALLEL - Mehrere Videos parallel (3x Speed)"
|
||||
echo ""
|
||||
read -p "Wähle Modell (1-4): " choice
|
||||
|
||||
case $choice in
|
||||
1)
|
||||
echo "→ Nutze TINY Modell für schnellen Test"
|
||||
read -p "YouTube URL: " url
|
||||
python3 transcriber_v3.py process "$url" --model tiny
|
||||
;;
|
||||
2)
|
||||
echo "→ Nutze LARGE Modell für beste Qualität"
|
||||
read -p "YouTube URL: " url
|
||||
python3 transcriber_v3.py process "$url" --model large
|
||||
;;
|
||||
3)
|
||||
echo "→ Scanne alle Playlists mit LARGE Modell"
|
||||
python3 transcriber_v3.py scan --model large
|
||||
;;
|
||||
4)
|
||||
echo "→ Parallel-Verarbeitung (3x schneller!)"
|
||||
echo "Gib URLs ein (mit Leerzeichen getrennt, oder Enter für Playlist):"
|
||||
read -p "URLs: " urls
|
||||
if [ -z "$urls" ]; then
|
||||
python3 transcriber_v4_parallel.py process --playlist people/rory-sutherland --model large
|
||||
else
|
||||
python3 transcriber_v4_parallel.py process --urls $urls --model large
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo "Ungültige Auswahl"
|
||||
;;
|
||||
esac
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
yt-dlp
|
||||
openai-whisper
|
||||
ffmpeg-python
|
||||
rich
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
#!/bin/bash
|
||||
# YouTube Transcriber - Start Script
|
||||
|
||||
echo "🎥 YouTube Transcriber System"
|
||||
echo "============================="
|
||||
echo ""
|
||||
|
||||
# Check if virtual environment exists
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
else
|
||||
source venv/bin/activate
|
||||
fi
|
||||
|
||||
# Start services
|
||||
echo "Starting services..."
|
||||
echo ""
|
||||
|
||||
# Start FastAPI backend
|
||||
echo "1️⃣ Starting API Server (Port 8000)..."
|
||||
uvicorn api_server:app --reload --host 0.0.0.0 --port 8000 &
|
||||
API_PID=$!
|
||||
|
||||
# Wait for API to start
|
||||
sleep 3
|
||||
|
||||
# Start Astro frontend
|
||||
echo "2️⃣ Starting Website (Port 4321)..."
|
||||
cd website && npx astro dev &
|
||||
WEB_PID=$!
|
||||
|
||||
echo ""
|
||||
echo "✅ System started!"
|
||||
echo ""
|
||||
echo "📍 Access points:"
|
||||
echo " • Public Website: http://localhost:4321"
|
||||
echo " • Admin Panel: http://localhost:4321/admin"
|
||||
echo " • API Docs: http://localhost:8000/docs"
|
||||
echo ""
|
||||
echo "Press CTRL+C to stop all services"
|
||||
|
||||
# Wait for interrupt
|
||||
trap "echo 'Stopping services...'; kill $API_PID $WEB_PID; exit" INT
|
||||
wait
|
||||
|
|
@ -1,294 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube Auto-Transcriber MVP
|
||||
Phase 1: Core Functionality - Download und Transkription
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import yt_dlp
|
||||
import whisper
|
||||
import warnings
|
||||
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
|
||||
|
||||
class YouTubeTranscriber:
|
||||
def __init__(self, model_size="base", output_dir="transcripts"):
|
||||
"""
|
||||
Initialisiert den Transcriber
|
||||
|
||||
Args:
|
||||
model_size: Whisper Model Größe (tiny, base, small, medium, large)
|
||||
output_dir: Ausgabe-Verzeichnis für Transkriptionen
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.temp_dir = Path("temp_audio")
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
print(f"Lade Whisper Model '{model_size}'...")
|
||||
self.model = whisper.load_model(model_size)
|
||||
print(f"Model geladen: {model_size}")
|
||||
|
||||
self.ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'postprocessors': [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192',
|
||||
}],
|
||||
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
}
|
||||
|
||||
def download_audio(self, url):
|
||||
"""
|
||||
Lädt Audio von YouTube herunter
|
||||
|
||||
Args:
|
||||
url: YouTube URL
|
||||
|
||||
Returns:
|
||||
Tuple (audio_path, video_info)
|
||||
"""
|
||||
print(f"\nLade Video von: {url}")
|
||||
|
||||
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
title = info.get('title', 'unknown')
|
||||
channel = info.get('uploader', 'unknown')
|
||||
duration = info.get('duration', 0)
|
||||
|
||||
# Finde die heruntergeladene Audio-Datei
|
||||
audio_file = None
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
|
||||
audio_file = file
|
||||
break
|
||||
|
||||
if not audio_file:
|
||||
raise Exception("Audio-Datei nicht gefunden")
|
||||
|
||||
print(f"✓ Download abgeschlossen: {title}")
|
||||
print(f" Kanal: {channel}")
|
||||
print(f" Dauer: {duration//60}:{duration%60:02d} Minuten")
|
||||
|
||||
return audio_file, {
|
||||
'title': title,
|
||||
'channel': channel,
|
||||
'duration': duration,
|
||||
'url': url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Fehler beim Download: {e}")
|
||||
return None, None
|
||||
|
||||
def transcribe_audio(self, audio_path, language="de"):
|
||||
"""
|
||||
Transkribiert Audio-Datei mit Whisper
|
||||
|
||||
Args:
|
||||
audio_path: Pfad zur Audio-Datei
|
||||
language: Sprache für Transkription
|
||||
|
||||
Returns:
|
||||
Transkriptionstext
|
||||
"""
|
||||
print(f"\nStarte Transkription...")
|
||||
print(f" Sprache: {language}")
|
||||
|
||||
try:
|
||||
result = self.model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
print(f"✓ Transkription abgeschlossen")
|
||||
print(f" Erkannte Sprache: {result.get('language', 'unbekannt')}")
|
||||
|
||||
return result['text']
|
||||
|
||||
except Exception as e:
|
||||
print(f"✗ Fehler bei Transkription: {e}")
|
||||
return None
|
||||
|
||||
def save_transcript(self, text, video_info):
|
||||
"""
|
||||
Speichert Transkript als Textdatei
|
||||
|
||||
Args:
|
||||
text: Transkriptionstext
|
||||
video_info: Video-Metadaten
|
||||
|
||||
Returns:
|
||||
Pfad zur gespeicherten Datei
|
||||
"""
|
||||
# Erstelle sicheren Dateinamen
|
||||
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{safe_title}_{timestamp}.txt"
|
||||
|
||||
# Erstelle Kanal-Ordner
|
||||
channel_dir = self.output_dir / video_info['channel'].replace('/', '_')
|
||||
channel_dir.mkdir(exist_ok=True)
|
||||
|
||||
filepath = channel_dir / filename
|
||||
|
||||
# Schreibe Transkript mit Metadaten
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(f"YouTube Transkription\n")
|
||||
f.write("=" * 50 + "\n\n")
|
||||
f.write(f"Titel: {video_info['title']}\n")
|
||||
f.write(f"Kanal: {video_info['channel']}\n")
|
||||
f.write(f"URL: {video_info['url']}\n")
|
||||
f.write(f"Dauer: {video_info['duration']//60}:{video_info['duration']%60:02d} Minuten\n")
|
||||
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
|
||||
f.write("\n" + "=" * 50 + "\n\n")
|
||||
f.write("TRANSKRIPTION:\n\n")
|
||||
f.write(text)
|
||||
|
||||
print(f"\n✓ Transkript gespeichert: {filepath}")
|
||||
return filepath
|
||||
|
||||
def cleanup_temp_files(self):
|
||||
"""Löscht temporäre Audio-Dateien"""
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
try:
|
||||
file.unlink()
|
||||
except:
|
||||
pass
|
||||
print("✓ Temporäre Dateien aufgeräumt")
|
||||
|
||||
def process_video(self, url, language="de"):
|
||||
"""
|
||||
Kompletter Workflow: Download → Transkription → Speichern
|
||||
|
||||
Args:
|
||||
url: YouTube URL
|
||||
language: Sprache für Transkription
|
||||
|
||||
Returns:
|
||||
Pfad zur Transkriptionsdatei oder None
|
||||
"""
|
||||
print("\n" + "=" * 60)
|
||||
print(f"VERARBEITE VIDEO")
|
||||
print("=" * 60)
|
||||
|
||||
# 1. Download Audio
|
||||
audio_path, video_info = self.download_audio(url)
|
||||
if not audio_path:
|
||||
return None
|
||||
|
||||
# 2. Transkribiere
|
||||
transcript = self.transcribe_audio(audio_path, language)
|
||||
if not transcript:
|
||||
return None
|
||||
|
||||
# 3. Speichern
|
||||
output_path = self.save_transcript(transcript, video_info)
|
||||
|
||||
# 4. Aufräumen
|
||||
self.cleanup_temp_files()
|
||||
|
||||
print("\n✓ Video erfolgreich verarbeitet!")
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='YouTube Video Transcriber - Transkribiert YouTube Videos mit Whisper'
|
||||
)
|
||||
parser.add_argument(
|
||||
'url',
|
||||
nargs='?',
|
||||
help='YouTube Video URL'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
default='base',
|
||||
choices=['tiny', 'base', 'small', 'medium', 'large'],
|
||||
help='Whisper Model Größe (default: base)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
default='de',
|
||||
help='Sprache für Transkription (default: de)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
default='transcripts',
|
||||
help='Ausgabe-Verzeichnis (default: transcripts)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch',
|
||||
action='store_true',
|
||||
help='Batch-Modus: URLs aus stdin lesen'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Initialisiere Transcriber
|
||||
transcriber = YouTubeTranscriber(
|
||||
model_size=args.model,
|
||||
output_dir=args.output
|
||||
)
|
||||
|
||||
if args.batch:
|
||||
# Batch-Modus: Lese URLs von stdin
|
||||
print("Batch-Modus: Gebe URLs ein (eine pro Zeile, beende mit Ctrl+D):")
|
||||
urls = []
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
url = line.strip()
|
||||
if url and url.startswith('http'):
|
||||
urls.append(url)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
print(f"\n{len(urls)} Videos zu verarbeiten")
|
||||
|
||||
for i, url in enumerate(urls, 1):
|
||||
print(f"\n[{i}/{len(urls)}] Verarbeite Video...")
|
||||
transcriber.process_video(url, args.language)
|
||||
|
||||
elif args.url:
|
||||
# Single Video
|
||||
transcriber.process_video(args.url, args.language)
|
||||
|
||||
else:
|
||||
# Interaktiver Modus
|
||||
print("\nYouTube Transcriber - Interaktiver Modus")
|
||||
print("=" * 50)
|
||||
print(f"Model: {args.model}")
|
||||
print(f"Sprache: {args.language}")
|
||||
print(f"Ausgabe: {args.output}/")
|
||||
print("=" * 50)
|
||||
print("\nGebe YouTube URL ein (oder 'q' zum Beenden):")
|
||||
|
||||
while True:
|
||||
try:
|
||||
url = input("\nURL: ").strip()
|
||||
if url.lower() in ['q', 'quit', 'exit']:
|
||||
break
|
||||
if url.startswith('http'):
|
||||
transcriber.process_video(url, args.language)
|
||||
else:
|
||||
print("Ungültige URL. Bitte YouTube URL eingeben.")
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
print("\nAuf Wiedersehen!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,476 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube Auto-Transcriber v2.0
|
||||
Mit verbesserter Download-Experience und Rich UI
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
import yt_dlp
|
||||
import whisper
|
||||
import warnings
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TextColumn,
|
||||
BarColumn,
|
||||
TaskProgressColumn,
|
||||
TimeRemainingColumn,
|
||||
TimeElapsedColumn,
|
||||
DownloadColumn,
|
||||
TransferSpeedColumn
|
||||
)
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.layout import Layout
|
||||
from rich import print as rprint
|
||||
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
|
||||
console = Console()
|
||||
|
||||
# ASCII Art Logo
|
||||
LOGO = """
|
||||
[bold cyan]╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ [bold white]🎥 YouTube Auto-Transcriber v2.0[/bold white] ║
|
||||
║ [dim]Powered by OpenAI Whisper & yt-dlp[/dim] ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════╝[/bold cyan]
|
||||
"""
|
||||
|
||||
class YouTubeTranscriber:
|
||||
def __init__(self, model_size="base", output_dir="transcripts", cache_dir=".cache"):
|
||||
"""
|
||||
Initialisiert den Transcriber mit Rich UI
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.cache_file = self.cache_dir / "transcribed_videos.json"
|
||||
|
||||
self.temp_dir = Path("temp_audio")
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Lade Cache
|
||||
self.cache = self.load_cache()
|
||||
|
||||
# Lade Whisper Model mit Progress
|
||||
with console.status(f"[bold green]⏳ Lade Whisper Model '{model_size}'...", spinner="dots"):
|
||||
self.model = whisper.load_model(model_size)
|
||||
|
||||
console.print(f"[bold green]✅ Model geladen: {model_size}[/bold green]")
|
||||
|
||||
# Model-Geschwindigkeiten (ungefähre Werte)
|
||||
self.model_speeds = {
|
||||
'tiny': 10,
|
||||
'base': 7,
|
||||
'small': 4,
|
||||
'medium': 2,
|
||||
'large': 1
|
||||
}
|
||||
self.model_size = model_size
|
||||
self.speed_factor = self.model_speeds.get(model_size, 3)
|
||||
|
||||
self.ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'postprocessors': [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192',
|
||||
}],
|
||||
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'progress_hooks': [self._download_progress_hook],
|
||||
}
|
||||
|
||||
self.current_progress = None
|
||||
self.download_task = None
|
||||
|
||||
def load_cache(self):
|
||||
"""Lädt den Cache bereits transkribierter Videos"""
|
||||
if self.cache_file.exists():
|
||||
with open(self.cache_file, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_cache(self):
|
||||
"""Speichert den Cache"""
|
||||
with open(self.cache_file, 'w') as f:
|
||||
json.dump(self.cache, f, indent=2)
|
||||
|
||||
def get_video_hash(self, url):
|
||||
"""Erstellt einen Hash für die Video-URL"""
|
||||
return hashlib.md5(url.encode()).hexdigest()
|
||||
|
||||
def is_cached(self, url):
|
||||
"""Prüft ob Video bereits transkribiert wurde"""
|
||||
video_hash = self.get_video_hash(url)
|
||||
if video_hash in self.cache:
|
||||
cached_info = self.cache[video_hash]
|
||||
output_file = Path(cached_info['output_file'])
|
||||
if output_file.exists():
|
||||
return cached_info
|
||||
return None
|
||||
|
||||
def _download_progress_hook(self, d):
|
||||
"""Progress Hook für yt-dlp"""
|
||||
if d['status'] == 'downloading' and self.download_task:
|
||||
if d.get('total_bytes'):
|
||||
downloaded = d.get('downloaded_bytes', 0)
|
||||
total = d['total_bytes']
|
||||
self.current_progress.update(self.download_task, completed=downloaded, total=total)
|
||||
elif d.get('total_bytes_estimate'):
|
||||
downloaded = d.get('downloaded_bytes', 0)
|
||||
total = d['total_bytes_estimate']
|
||||
self.current_progress.update(self.download_task, completed=downloaded, total=total)
|
||||
|
||||
def get_video_info(self, url):
|
||||
"""
|
||||
Holt Video-Informationen VOR dem Download
|
||||
"""
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
return {
|
||||
'title': info.get('title', 'Unbekannt'),
|
||||
'channel': info.get('uploader', 'Unbekannt'),
|
||||
'duration': info.get('duration', 0),
|
||||
'view_count': info.get('view_count', 0),
|
||||
'upload_date': info.get('upload_date', ''),
|
||||
'description': info.get('description', '')[:200],
|
||||
'filesize': info.get('filesize', 0) or info.get('filesize_approx', 0)
|
||||
}
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Fehler beim Abrufen der Video-Info: {e}[/red]")
|
||||
return None
|
||||
|
||||
def display_video_info(self, info):
|
||||
"""Zeigt Video-Informationen in einer schönen Tabelle"""
|
||||
if not info:
|
||||
return
|
||||
|
||||
# Erstelle Info-Tabelle
|
||||
table = Table(title="📹 Video Information", show_header=False, box=None)
|
||||
table.add_column("Property", style="cyan", width=20)
|
||||
table.add_column("Value", style="white")
|
||||
|
||||
table.add_row("Titel", info['title'][:60] + "..." if len(info['title']) > 60 else info['title'])
|
||||
table.add_row("Kanal", info['channel'])
|
||||
|
||||
duration = info['duration']
|
||||
duration_str = f"{duration//60}:{duration%60:02d} Minuten"
|
||||
table.add_row("Dauer", duration_str)
|
||||
|
||||
# Zeitschätzung für Transkription
|
||||
estimated_time = duration / self.speed_factor
|
||||
eta_str = f"~{estimated_time//60:.0f}:{estimated_time%60:02.0f} Minuten"
|
||||
table.add_row("Geschätzte Zeit", f"{eta_str} (mit {self.model_size} model)")
|
||||
|
||||
if info.get('view_count'):
|
||||
views = f"{info['view_count']:,}".replace(',', '.')
|
||||
table.add_row("Aufrufe", views)
|
||||
|
||||
console.print(Panel(table, border_style="cyan"))
|
||||
|
||||
# Warnung bei langen Videos
|
||||
if duration > 1800: # 30 Minuten
|
||||
console.print(f"[yellow]⚠️ Hinweis: Dieses Video ist über 30 Minuten lang. Die Transkription kann einige Zeit dauern.[/yellow]")
|
||||
|
||||
return estimated_time
|
||||
|
||||
def download_audio(self, url, progress):
|
||||
"""
|
||||
Lädt Audio mit Progress Bar herunter
|
||||
"""
|
||||
self.current_progress = progress
|
||||
self.download_task = progress.add_task(
|
||||
"[cyan]📥 Download Audio...",
|
||||
total=None
|
||||
)
|
||||
|
||||
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
title = info.get('title', 'unknown')
|
||||
channel = info.get('uploader', 'unknown')
|
||||
duration = info.get('duration', 0)
|
||||
|
||||
# Finde die heruntergeladene Audio-Datei
|
||||
audio_file = None
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
|
||||
audio_file = file
|
||||
break
|
||||
|
||||
if not audio_file:
|
||||
raise Exception("Audio-Datei nicht gefunden")
|
||||
|
||||
progress.update(self.download_task, completed=100, total=100)
|
||||
|
||||
return audio_file, {
|
||||
'title': title,
|
||||
'channel': channel,
|
||||
'duration': duration,
|
||||
'url': url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Fehler beim Download: {e}[/red]")
|
||||
return None, None
|
||||
|
||||
def transcribe_audio(self, audio_path, language="de", progress=None):
|
||||
"""
|
||||
Transkribiert Audio-Datei mit Progress Bar
|
||||
"""
|
||||
if progress:
|
||||
task = progress.add_task(
|
||||
f"[green]🎙️ Transkribiere mit {self.model_size} model...",
|
||||
total=100
|
||||
)
|
||||
|
||||
try:
|
||||
# Simuliere Progress (Whisper hat keine direkte Progress-API)
|
||||
def progress_callback(current, total):
|
||||
if progress:
|
||||
progress.update(task, completed=min(current, 100))
|
||||
|
||||
result = self.model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
verbose=False,
|
||||
fp16=False # Für M1 Mac
|
||||
)
|
||||
|
||||
if progress:
|
||||
progress.update(task, completed=100)
|
||||
|
||||
return result['text'], result.get('language', 'unbekannt')
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Fehler bei Transkription: {e}[/red]")
|
||||
return None, None
|
||||
|
||||
def save_transcript(self, text, video_info, detected_language=None):
|
||||
"""
|
||||
Speichert Transkript als Textdatei
|
||||
"""
|
||||
# Erstelle sicheren Dateinamen
|
||||
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{safe_title}_{timestamp}.txt"
|
||||
|
||||
# Erstelle Kanal-Ordner
|
||||
channel_dir = self.output_dir / video_info['channel'].replace('/', '_')
|
||||
channel_dir.mkdir(exist_ok=True)
|
||||
|
||||
filepath = channel_dir / filename
|
||||
|
||||
# Schreibe Transkript mit Metadaten
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(f"YouTube Transkription\n")
|
||||
f.write("=" * 50 + "\n\n")
|
||||
f.write(f"Titel: {video_info['title']}\n")
|
||||
f.write(f"Kanal: {video_info['channel']}\n")
|
||||
f.write(f"URL: {video_info['url']}\n")
|
||||
f.write(f"Dauer: {video_info['duration']//60}:{video_info['duration']%60:02d} Minuten\n")
|
||||
if detected_language:
|
||||
f.write(f"Erkannte Sprache: {detected_language}\n")
|
||||
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
|
||||
f.write(f"Whisper Model: {self.model_size}\n")
|
||||
f.write("\n" + "=" * 50 + "\n\n")
|
||||
f.write("TRANSKRIPTION:\n\n")
|
||||
f.write(text)
|
||||
|
||||
return filepath
|
||||
|
||||
def cleanup_temp_files(self):
|
||||
"""Löscht temporäre Audio-Dateien"""
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
try:
|
||||
file.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
def process_video(self, url, language="de", force_reprocess=False):
|
||||
"""
|
||||
Kompletter Workflow mit Rich UI
|
||||
"""
|
||||
console.rule(f"[bold blue]Verarbeite Video[/bold blue]")
|
||||
|
||||
# Prüfe Cache
|
||||
if not force_reprocess:
|
||||
cached = self.is_cached(url)
|
||||
if cached:
|
||||
console.print(f"[yellow]⚠️ Video bereits transkribiert:[/yellow]")
|
||||
console.print(f" 📁 {cached['output_file']}")
|
||||
console.print(f" 📅 {cached['transcribed_at']}")
|
||||
console.print(f"[dim] (Nutze --force um neu zu transkribieren)[/dim]")
|
||||
return cached['output_file']
|
||||
|
||||
# Hole Video-Info vorab
|
||||
console.print("\n[cyan]📊 Lade Video-Informationen...[/cyan]")
|
||||
video_info = self.get_video_info(url)
|
||||
if not video_info:
|
||||
return None
|
||||
|
||||
estimated_time = self.display_video_info(video_info)
|
||||
|
||||
# Multi-Progress für Download und Transkription
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
TaskProgressColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console
|
||||
) as progress:
|
||||
|
||||
# 1. Download Audio
|
||||
audio_path, download_info = self.download_audio(url, progress)
|
||||
if not audio_path:
|
||||
return None
|
||||
|
||||
# 2. Transkribiere
|
||||
transcript, detected_lang = self.transcribe_audio(audio_path, language, progress)
|
||||
if not transcript:
|
||||
return None
|
||||
|
||||
# 3. Speichern
|
||||
output_path = self.save_transcript(transcript, download_info, detected_lang)
|
||||
|
||||
# 4. Cache aktualisieren
|
||||
video_hash = self.get_video_hash(url)
|
||||
self.cache[video_hash] = {
|
||||
'url': url,
|
||||
'title': download_info['title'],
|
||||
'output_file': str(output_path),
|
||||
'transcribed_at': datetime.now().isoformat(),
|
||||
'model': self.model_size,
|
||||
'language': detected_lang
|
||||
}
|
||||
self.save_cache()
|
||||
|
||||
# 5. Aufräumen
|
||||
self.cleanup_temp_files()
|
||||
|
||||
# Erfolgs-Meldung
|
||||
console.print("\n[bold green]✅ Video erfolgreich verarbeitet![/bold green]")
|
||||
console.print(f"📁 Gespeichert: [cyan]{output_path}[/cyan]")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='YouTube Video Transcriber v2.0 - Mit verbesserter UI'
|
||||
)
|
||||
parser.add_argument(
|
||||
'url',
|
||||
nargs='?',
|
||||
help='YouTube Video URL'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
default='base',
|
||||
choices=['tiny', 'base', 'small', 'medium', 'large'],
|
||||
help='Whisper Model Größe (default: base)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
default='de',
|
||||
help='Sprache für Transkription (default: de)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
default='transcripts',
|
||||
help='Ausgabe-Verzeichnis (default: transcripts)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--batch',
|
||||
action='store_true',
|
||||
help='Batch-Modus: URLs aus stdin lesen'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--force',
|
||||
action='store_true',
|
||||
help='Ignoriere Cache und transkribiere neu'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Zeige Logo
|
||||
console.print(LOGO)
|
||||
|
||||
# Initialisiere Transcriber
|
||||
transcriber = YouTubeTranscriber(
|
||||
model_size=args.model,
|
||||
output_dir=args.output
|
||||
)
|
||||
|
||||
if args.batch:
|
||||
# Batch-Modus
|
||||
console.print("[cyan]📋 Batch-Modus: Gebe URLs ein (eine pro Zeile, beende mit Ctrl+D):[/cyan]")
|
||||
urls = []
|
||||
try:
|
||||
for line in sys.stdin:
|
||||
url = line.strip()
|
||||
if url and url.startswith('http'):
|
||||
urls.append(url)
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
console.print(f"\n[bold]{len(urls)} Videos zu verarbeiten[/bold]")
|
||||
|
||||
for i, url in enumerate(urls, 1):
|
||||
console.print(f"\n[bold cyan]━━━ Video {i}/{len(urls)} ━━━[/bold cyan]")
|
||||
transcriber.process_video(url, args.language, args.force)
|
||||
|
||||
elif args.url:
|
||||
# Single Video
|
||||
transcriber.process_video(args.url, args.language, args.force)
|
||||
|
||||
else:
|
||||
# Interaktiver Modus
|
||||
console.print("[bold cyan]🎬 Interaktiver Modus[/bold cyan]")
|
||||
console.print(f"Model: [green]{args.model}[/green]")
|
||||
console.print(f"Sprache: [green]{args.language}[/green]")
|
||||
console.print(f"Ausgabe: [green]{args.output}/[/green]")
|
||||
console.print("\nGebe YouTube URL ein (oder 'q' zum Beenden):\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
url = console.input("[bold cyan]URL ▶ [/bold cyan]").strip()
|
||||
if url.lower() in ['q', 'quit', 'exit']:
|
||||
break
|
||||
if url.startswith('http'):
|
||||
transcriber.process_video(url, args.language, args.force)
|
||||
else:
|
||||
console.print("[red]❌ Ungültige URL. Bitte YouTube URL eingeben.[/red]")
|
||||
except KeyboardInterrupt:
|
||||
break
|
||||
|
||||
console.print("\n[bold green]👋 Auf Wiedersehen![/bold green]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,603 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube Auto-Transcriber v3.0
|
||||
Mit Playlist-Management und Themen-Ordnern
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
import time
|
||||
from typing import List, Dict, Tuple
|
||||
import yt_dlp
|
||||
import whisper
|
||||
import warnings
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TextColumn,
|
||||
BarColumn,
|
||||
TaskProgressColumn,
|
||||
TimeRemainingColumn,
|
||||
TimeElapsedColumn,
|
||||
MofNCompleteColumn
|
||||
)
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.tree import Tree
|
||||
from rich import print as rprint
|
||||
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
|
||||
console = Console()
|
||||
|
||||
# ASCII Art Logo
|
||||
LOGO = """
|
||||
[bold cyan]╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ [bold white]🎥 YouTube Auto-Transcriber v3.0[/bold white] ║
|
||||
║ [dim]Playlist Management & Batch Processing[/dim] ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════╝[/bold cyan]
|
||||
"""
|
||||
|
||||
class PlaylistManager:
|
||||
"""
|
||||
Verwaltet Playlists und URL-Listen
|
||||
"""
|
||||
def __init__(self, playlists_dir="playlists"):
|
||||
self.playlists_dir = Path(playlists_dir)
|
||||
self.playlists_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Erstelle Beispiel-Struktur wenn leer
|
||||
self._create_example_structure()
|
||||
|
||||
def _create_example_structure(self):
|
||||
"""Erstellt Beispiel-Ordnerstruktur"""
|
||||
example_file = self.playlists_dir / "example_tech.txt"
|
||||
if not example_file.exists() and not any(self.playlists_dir.glob("*.txt")):
|
||||
with open(example_file, 'w') as f:
|
||||
f.write("# Tech Videos - Beispiel Playlist\n")
|
||||
f.write("# Zeilen mit # werden ignoriert\n")
|
||||
f.write("# Eine URL pro Zeile:\n")
|
||||
f.write("#\n")
|
||||
f.write("# https://www.youtube.com/watch?v=VIDEO_ID\n")
|
||||
|
||||
def get_all_playlists(self) -> Dict[str, Path]:
|
||||
"""Findet alle Playlist-Dateien"""
|
||||
playlists = {}
|
||||
|
||||
# Suche .txt Dateien im Hauptordner
|
||||
for file in self.playlists_dir.glob("*.txt"):
|
||||
name = file.stem
|
||||
playlists[name] = file
|
||||
|
||||
# Suche auch in Unterordnern
|
||||
for folder in self.playlists_dir.iterdir():
|
||||
if folder.is_dir():
|
||||
for file in folder.glob("*.txt"):
|
||||
name = f"{folder.name}/{file.stem}"
|
||||
playlists[name] = file
|
||||
|
||||
return playlists
|
||||
|
||||
def read_playlist(self, playlist_path: Path) -> List[str]:
|
||||
"""Liest URLs aus einer Playlist-Datei"""
|
||||
urls = []
|
||||
if not playlist_path.exists():
|
||||
return urls
|
||||
|
||||
with open(playlist_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
# Ignoriere leere Zeilen und Kommentare
|
||||
if line and not line.startswith('#'):
|
||||
if 'youtube.com' in line or 'youtu.be' in line:
|
||||
urls.append(line)
|
||||
|
||||
return urls
|
||||
|
||||
def display_playlists_tree(self):
|
||||
"""Zeigt alle Playlists als Baum-Struktur"""
|
||||
tree = Tree("[bold cyan]📁 Playlists[/bold cyan]")
|
||||
|
||||
# Hauptordner-Dateien
|
||||
for file in sorted(self.playlists_dir.glob("*.txt")):
|
||||
urls = self.read_playlist(file)
|
||||
tree.add(f"📄 {file.stem} ({len(urls)} URLs)")
|
||||
|
||||
# Unterordner
|
||||
for folder in sorted(self.playlists_dir.iterdir()):
|
||||
if folder.is_dir():
|
||||
branch = tree.add(f"📂 {folder.name}/")
|
||||
for file in sorted(folder.glob("*.txt")):
|
||||
urls = self.read_playlist(file)
|
||||
branch.add(f"📄 {file.stem} ({len(urls)} URLs)")
|
||||
|
||||
console.print(tree)
|
||||
return tree
|
||||
|
||||
|
||||
class YouTubeTranscriber:
|
||||
def __init__(self, model_size="base", output_dir="transcripts", cache_dir=".cache"):
|
||||
"""
|
||||
Initialisiert den Transcriber mit Rich UI
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.cache_file = self.cache_dir / "transcribed_videos.json"
|
||||
|
||||
self.temp_dir = Path("temp_audio")
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Lade Cache
|
||||
self.cache = self.load_cache()
|
||||
|
||||
# Lade Whisper Model mit Progress
|
||||
with console.status(f"[bold green]⏳ Lade Whisper Model '{model_size}'...", spinner="dots"):
|
||||
self.model = whisper.load_model(model_size)
|
||||
|
||||
console.print(f"[bold green]✅ Model geladen: {model_size}[/bold green]")
|
||||
|
||||
# Model-Geschwindigkeiten
|
||||
self.model_speeds = {
|
||||
'tiny': 10,
|
||||
'base': 7,
|
||||
'small': 4,
|
||||
'medium': 2,
|
||||
'large': 1
|
||||
}
|
||||
self.model_size = model_size
|
||||
self.speed_factor = self.model_speeds.get(model_size, 3)
|
||||
|
||||
self.ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'postprocessors': [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192',
|
||||
}],
|
||||
'outtmpl': str(self.temp_dir / '%(title)s.%(ext)s'),
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'progress_hooks': [self._download_progress_hook],
|
||||
}
|
||||
|
||||
self.current_progress = None
|
||||
self.download_task = None
|
||||
|
||||
def load_cache(self):
|
||||
"""Lädt den Cache bereits transkribierter Videos"""
|
||||
if self.cache_file.exists():
|
||||
with open(self.cache_file, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_cache(self):
|
||||
"""Speichert den Cache"""
|
||||
with open(self.cache_file, 'w') as f:
|
||||
json.dump(self.cache, f, indent=2)
|
||||
|
||||
def get_video_hash(self, url):
|
||||
"""Erstellt einen Hash für die Video-URL"""
|
||||
return hashlib.md5(url.encode()).hexdigest()
|
||||
|
||||
def is_cached(self, url):
|
||||
"""Prüft ob Video bereits transkribiert wurde"""
|
||||
video_hash = self.get_video_hash(url)
|
||||
if video_hash in self.cache:
|
||||
cached_info = self.cache[video_hash]
|
||||
output_file = Path(cached_info['output_file'])
|
||||
if output_file.exists():
|
||||
return cached_info
|
||||
return None
|
||||
|
||||
def _download_progress_hook(self, d):
|
||||
"""Progress Hook für yt-dlp"""
|
||||
if d['status'] == 'downloading' and self.download_task:
|
||||
if d.get('total_bytes'):
|
||||
downloaded = d.get('downloaded_bytes', 0)
|
||||
total = d['total_bytes']
|
||||
self.current_progress.update(self.download_task, completed=downloaded, total=total)
|
||||
|
||||
def get_video_info(self, url):
|
||||
"""Holt Video-Informationen VOR dem Download"""
|
||||
ydl_opts = {
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
'extract_flat': False,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=False)
|
||||
return {
|
||||
'title': info.get('title', 'Unbekannt'),
|
||||
'channel': info.get('uploader', 'Unbekannt'),
|
||||
'duration': info.get('duration', 0),
|
||||
'url': url
|
||||
}
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Fehler beim Abrufen der Video-Info: {e}[/red]")
|
||||
return None
|
||||
|
||||
def download_audio(self, url, progress=None):
|
||||
"""Lädt Audio mit Progress Bar herunter"""
|
||||
self.current_progress = progress
|
||||
if progress:
|
||||
self.download_task = progress.add_task(
|
||||
"[cyan]📥 Download...",
|
||||
total=None
|
||||
)
|
||||
|
||||
with yt_dlp.YoutubeDL(self.ydl_opts) as ydl:
|
||||
try:
|
||||
info = ydl.extract_info(url, download=True)
|
||||
title = info.get('title', 'unknown')
|
||||
channel = info.get('uploader', 'unknown')
|
||||
duration = info.get('duration', 0)
|
||||
|
||||
# Finde die heruntergeladene Audio-Datei
|
||||
audio_file = None
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
if file.stat().st_mtime > (datetime.now().timestamp() - 60):
|
||||
audio_file = file
|
||||
break
|
||||
|
||||
if not audio_file:
|
||||
raise Exception("Audio-Datei nicht gefunden")
|
||||
|
||||
if progress and self.download_task:
|
||||
progress.update(self.download_task, completed=100, total=100)
|
||||
|
||||
return audio_file, {
|
||||
'title': title,
|
||||
'channel': channel,
|
||||
'duration': duration,
|
||||
'url': url
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Download-Fehler: {e}[/red]")
|
||||
return None, None
|
||||
|
||||
def transcribe_audio(self, audio_path, language="de", progress=None):
|
||||
"""Transkribiert Audio-Datei"""
|
||||
if progress:
|
||||
task = progress.add_task(
|
||||
f"[green]🎙️ Transkribiere...",
|
||||
total=100
|
||||
)
|
||||
|
||||
try:
|
||||
result = self.model.transcribe(
|
||||
str(audio_path),
|
||||
language=language,
|
||||
verbose=False,
|
||||
fp16=False
|
||||
)
|
||||
|
||||
if progress:
|
||||
progress.update(task, completed=100)
|
||||
|
||||
return result['text'], result.get('language', 'unbekannt')
|
||||
|
||||
except Exception as e:
|
||||
console.print(f"[red]❌ Transkriptions-Fehler: {e}[/red]")
|
||||
return None, None
|
||||
|
||||
def save_transcript(self, text, video_info, playlist_name=None):
|
||||
"""Speichert Transkript mit optionalem Playlist-Ordner"""
|
||||
# Basis-Ordner
|
||||
base_dir = self.output_dir
|
||||
|
||||
# Wenn Playlist, erstelle Unterordner
|
||||
if playlist_name:
|
||||
base_dir = base_dir / playlist_name.replace('/', '_')
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Kanal-Ordner
|
||||
channel_dir = base_dir / video_info['channel'].replace('/', '_')
|
||||
channel_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Dateiname
|
||||
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{safe_title}_{timestamp}.txt"
|
||||
|
||||
filepath = channel_dir / filename
|
||||
|
||||
# Schreibe Transkript
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(f"YouTube Transkription\n")
|
||||
f.write("=" * 50 + "\n\n")
|
||||
f.write(f"Titel: {video_info['title']}\n")
|
||||
f.write(f"Kanal: {video_info['channel']}\n")
|
||||
f.write(f"URL: {video_info['url']}\n")
|
||||
if playlist_name:
|
||||
f.write(f"Playlist: {playlist_name}\n")
|
||||
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
|
||||
f.write(f"Whisper Model: {self.model_size}\n")
|
||||
f.write("\n" + "=" * 50 + "\n\n")
|
||||
f.write("TRANSKRIPTION:\n\n")
|
||||
f.write(text)
|
||||
|
||||
return filepath
|
||||
|
||||
def cleanup_temp_files(self):
|
||||
"""Löscht temporäre Audio-Dateien"""
|
||||
for file in self.temp_dir.glob("*.mp3"):
|
||||
try:
|
||||
file.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
def process_video(self, url, language="de", playlist_name=None, progress=None):
|
||||
"""Verarbeitet ein einzelnes Video"""
|
||||
# Prüfe Cache
|
||||
cached = self.is_cached(url)
|
||||
if cached:
|
||||
return cached['output_file'], True # True = war gecached
|
||||
|
||||
# Hole Video-Info
|
||||
video_info = self.get_video_info(url)
|
||||
if not video_info:
|
||||
return None, False
|
||||
|
||||
# Download Audio
|
||||
audio_path, download_info = self.download_audio(url, progress)
|
||||
if not audio_path:
|
||||
return None, False
|
||||
|
||||
# Transkribiere
|
||||
transcript, detected_lang = self.transcribe_audio(audio_path, language, progress)
|
||||
if not transcript:
|
||||
return None, False
|
||||
|
||||
# Speichern
|
||||
output_path = self.save_transcript(transcript, download_info, playlist_name)
|
||||
|
||||
# Cache aktualisieren
|
||||
video_hash = self.get_video_hash(url)
|
||||
self.cache[video_hash] = {
|
||||
'url': url,
|
||||
'title': download_info['title'],
|
||||
'output_file': str(output_path),
|
||||
'transcribed_at': datetime.now().isoformat(),
|
||||
'model': self.model_size,
|
||||
'playlist': playlist_name
|
||||
}
|
||||
self.save_cache()
|
||||
|
||||
# Aufräumen
|
||||
self.cleanup_temp_files()
|
||||
|
||||
return output_path, False # False = neu transkribiert
|
||||
|
||||
def process_playlist(self, playlist_name: str, urls: List[str], language="de"):
|
||||
"""
|
||||
Verarbeitet eine komplette Playlist
|
||||
"""
|
||||
console.rule(f"[bold cyan]📋 Playlist: {playlist_name}[/bold cyan]")
|
||||
|
||||
# Filtere bereits transkribierte Videos
|
||||
new_urls = []
|
||||
cached_count = 0
|
||||
|
||||
for url in urls:
|
||||
if self.is_cached(url):
|
||||
cached_count += 1
|
||||
else:
|
||||
new_urls.append(url)
|
||||
|
||||
# Status-Übersicht
|
||||
table = Table(show_header=False, box=None)
|
||||
table.add_column("Info", style="cyan")
|
||||
table.add_column("Wert", style="white")
|
||||
|
||||
table.add_row("📊 Gesamt Videos:", str(len(urls)))
|
||||
table.add_row("✅ Bereits transkribiert:", str(cached_count))
|
||||
table.add_row("🆕 Neu zu transkribieren:", str(len(new_urls)))
|
||||
|
||||
console.print(Panel(table, title="Playlist Status", border_style="cyan"))
|
||||
|
||||
if not new_urls:
|
||||
console.print("[green]✅ Alle Videos bereits transkribiert![/green]")
|
||||
return
|
||||
|
||||
# Verarbeite neue Videos
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
console=console
|
||||
) as progress:
|
||||
|
||||
playlist_task = progress.add_task(
|
||||
f"[cyan]Verarbeite {playlist_name}...",
|
||||
total=len(new_urls)
|
||||
)
|
||||
|
||||
for i, url in enumerate(new_urls, 1):
|
||||
progress.update(
|
||||
playlist_task,
|
||||
description=f"[cyan]Video {i}/{len(new_urls)}..."
|
||||
)
|
||||
|
||||
# Verarbeite Video
|
||||
output_path, was_cached = self.process_video(
|
||||
url,
|
||||
language,
|
||||
playlist_name,
|
||||
progress
|
||||
)
|
||||
|
||||
if output_path:
|
||||
success_count += 1
|
||||
console.print(f" ✅ {Path(output_path).name}")
|
||||
else:
|
||||
error_count += 1
|
||||
console.print(f" ❌ Fehler bei: {url}")
|
||||
|
||||
progress.update(playlist_task, advance=1)
|
||||
|
||||
# Zusammenfassung
|
||||
console.print("\n" + "=" * 50)
|
||||
console.print(f"[bold green]✅ Erfolgreich: {success_count}[/bold green]")
|
||||
if error_count > 0:
|
||||
console.print(f"[bold red]❌ Fehler: {error_count}[/bold red]")
|
||||
console.print(f"[bold cyan]📁 Gespeichert in: {self.output_dir}/{playlist_name}/[/bold cyan]")
|
||||
|
||||
|
||||
def process_all_playlists(transcriber, playlist_manager, language="de"):
|
||||
"""Verarbeitet alle Playlists"""
|
||||
playlists = playlist_manager.get_all_playlists()
|
||||
|
||||
if not playlists:
|
||||
console.print("[yellow]⚠️ Keine Playlists gefunden![/yellow]")
|
||||
console.print(f"Erstelle .txt Dateien in: {playlist_manager.playlists_dir}/")
|
||||
return
|
||||
|
||||
console.print(f"\n[bold cyan]🔍 Gefundene Playlists:[/bold cyan]")
|
||||
playlist_manager.display_playlists_tree()
|
||||
|
||||
# Statistiken sammeln
|
||||
total_urls = 0
|
||||
total_new = 0
|
||||
|
||||
for name, path in playlists.items():
|
||||
urls = playlist_manager.read_playlist(path)
|
||||
new_count = sum(1 for url in urls if not transcriber.is_cached(url))
|
||||
total_urls += len(urls)
|
||||
total_new += new_count
|
||||
|
||||
console.print(f"\n[bold]📊 Gesamt: {total_urls} Videos, {total_new} neu zu transkribieren[/bold]")
|
||||
|
||||
# Verarbeite jede Playlist
|
||||
for name, path in playlists.items():
|
||||
urls = playlist_manager.read_playlist(path)
|
||||
if urls:
|
||||
console.print(f"\n" + "=" * 60)
|
||||
transcriber.process_playlist(name, urls, language)
|
||||
|
||||
console.print("\n[bold green]🎉 Alle Playlists verarbeitet![/bold green]")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='YouTube Transcriber v3.0 - Playlist Management'
|
||||
)
|
||||
parser.add_argument(
|
||||
'command',
|
||||
nargs='?',
|
||||
choices=['scan', 'list', 'process'],
|
||||
default='scan',
|
||||
help='Befehl: scan (alle Playlists), list (zeige Playlists), process (einzelne URL)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'url',
|
||||
nargs='?',
|
||||
help='YouTube URL (nur für process)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--playlist',
|
||||
help='Spezifische Playlist verarbeiten'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
default='base',
|
||||
choices=['tiny', 'base', 'small', 'medium', 'large'],
|
||||
help='Whisper Model (default: base)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
default='de',
|
||||
help='Sprache (default: de)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--playlists-dir',
|
||||
default='playlists',
|
||||
help='Ordner mit Playlist-Dateien (default: playlists)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--output',
|
||||
default='transcripts',
|
||||
help='Ausgabe-Ordner (default: transcripts)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Zeige Logo
|
||||
console.print(LOGO)
|
||||
|
||||
# Initialisiere Manager
|
||||
playlist_manager = PlaylistManager(args.playlists_dir)
|
||||
transcriber = YouTubeTranscriber(
|
||||
model_size=args.model,
|
||||
output_dir=args.output
|
||||
)
|
||||
|
||||
if args.command == 'list':
|
||||
# Zeige nur Playlists
|
||||
playlists = playlist_manager.get_all_playlists()
|
||||
if playlists:
|
||||
console.print("[bold cyan]📁 Verfügbare Playlists:[/bold cyan]\n")
|
||||
playlist_manager.display_playlists_tree()
|
||||
|
||||
# Zeige Details
|
||||
console.print("\n[bold]Details:[/bold]")
|
||||
for name, path in playlists.items():
|
||||
urls = playlist_manager.read_playlist(path)
|
||||
new_count = sum(1 for url in urls if not transcriber.is_cached(url))
|
||||
console.print(f" • {name}: {len(urls)} URLs ({new_count} neu)")
|
||||
else:
|
||||
console.print("[yellow]Keine Playlists gefunden![/yellow]")
|
||||
console.print(f"Erstelle .txt Dateien in: {args.playlists_dir}/")
|
||||
|
||||
elif args.command == 'process':
|
||||
# Verarbeite einzelne URL
|
||||
if args.url:
|
||||
output, _ = transcriber.process_video(args.url, args.language)
|
||||
if output:
|
||||
console.print(f"[green]✅ Gespeichert: {output}[/green]")
|
||||
else:
|
||||
console.print("[red]❌ Bitte URL angeben für 'process' Befehl[/red]")
|
||||
|
||||
elif args.command == 'scan':
|
||||
# Verarbeite Playlists
|
||||
if args.playlist:
|
||||
# Spezifische Playlist
|
||||
playlists = playlist_manager.get_all_playlists()
|
||||
if args.playlist in playlists:
|
||||
path = playlists[args.playlist]
|
||||
urls = playlist_manager.read_playlist(path)
|
||||
transcriber.process_playlist(args.playlist, urls, args.language)
|
||||
else:
|
||||
console.print(f"[red]❌ Playlist '{args.playlist}' nicht gefunden![/red]")
|
||||
console.print("Verfügbare Playlists:")
|
||||
for name in playlists.keys():
|
||||
console.print(f" • {name}")
|
||||
else:
|
||||
# Alle Playlists
|
||||
process_all_playlists(transcriber, playlist_manager, args.language)
|
||||
|
||||
console.print("\n[bold green]✨ Fertig![/bold green]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,559 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
YouTube Auto-Transcriber v4.0 - PARALLEL EDITION
|
||||
Mit Multi-Threading für 3-4x schnellere Verarbeitung
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
import time
|
||||
from typing import List, Dict, Tuple, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from queue import Queue, Empty
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
import multiprocessing
|
||||
|
||||
import yt_dlp
|
||||
import whisper
|
||||
import warnings
|
||||
|
||||
from rich.console import Console
|
||||
from rich.progress import (
|
||||
Progress,
|
||||
SpinnerColumn,
|
||||
TextColumn,
|
||||
BarColumn,
|
||||
TaskProgressColumn,
|
||||
TimeRemainingColumn,
|
||||
TimeElapsedColumn,
|
||||
MofNCompleteColumn
|
||||
)
|
||||
from rich.table import Table
|
||||
from rich.panel import Panel
|
||||
from rich.live import Live
|
||||
from rich.layout import Layout
|
||||
from rich.columns import Columns
|
||||
from rich import print as rprint
|
||||
|
||||
warnings.filterwarnings("ignore", category=FutureWarning)
|
||||
warnings.filterwarnings("ignore", category=UserWarning)
|
||||
|
||||
console = Console()
|
||||
|
||||
# ASCII Art Logo
|
||||
LOGO = """
|
||||
[bold cyan]╔═══════════════════════════════════════════════════════╗
|
||||
║ ║
|
||||
║ [bold white]🚀 YouTube Transcriber v4.0 - PARALLEL[/bold white] ║
|
||||
║ [dim]Multi-Threading für 3-4x Speed![/dim] ║
|
||||
║ ║
|
||||
╚═══════════════════════════════════════════════════════╝[/bold cyan]
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class VideoJob:
|
||||
"""Datenklasse für Video-Jobs"""
|
||||
url: str
|
||||
playlist_name: Optional[str] = None
|
||||
language: str = "de"
|
||||
status: str = "pending" # pending, downloading, transcribing, completed, failed
|
||||
error: Optional[str] = None
|
||||
output_path: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
duration: Optional[int] = None
|
||||
|
||||
|
||||
class ParallelTranscriber:
|
||||
def __init__(self,
|
||||
model_size="base",
|
||||
output_dir="transcripts",
|
||||
cache_dir=".cache",
|
||||
max_downloads=3,
|
||||
max_transcriptions=2):
|
||||
"""
|
||||
Initialisiert den Parallel-Transcriber
|
||||
|
||||
Args:
|
||||
max_downloads: Maximale parallele Downloads
|
||||
max_transcriptions: Maximale parallele Transkriptionen
|
||||
"""
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(exist_ok=True)
|
||||
|
||||
self.cache_dir = Path(cache_dir)
|
||||
self.cache_dir.mkdir(exist_ok=True)
|
||||
self.cache_file = self.cache_dir / "transcribed_videos.json"
|
||||
|
||||
self.temp_dir = Path("temp_audio")
|
||||
self.temp_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Parallel-Processing Settings
|
||||
self.max_downloads = max_downloads
|
||||
self.max_transcriptions = max_transcriptions
|
||||
|
||||
# Optimale Werte für M1/M2 Macs
|
||||
if model_size == "large":
|
||||
self.max_transcriptions = min(2, max_transcriptions) # Max 2 Large-Modelle parallel
|
||||
elif model_size in ["tiny", "base"]:
|
||||
self.max_transcriptions = min(4, max_transcriptions) # Bis zu 4 kleine Modelle
|
||||
|
||||
# Queues für Pipeline
|
||||
self.download_queue = Queue()
|
||||
self.transcribe_queue = Queue()
|
||||
self.completed_queue = Queue()
|
||||
|
||||
# Thread Pools
|
||||
self.download_pool = ThreadPoolExecutor(max_workers=self.max_downloads)
|
||||
self.transcribe_pool = ThreadPoolExecutor(max_workers=self.max_transcriptions)
|
||||
|
||||
# Jobs tracking
|
||||
self.jobs: Dict[str, VideoJob] = {}
|
||||
self.lock = threading.Lock()
|
||||
|
||||
# Lade Cache
|
||||
self.cache = self.load_cache()
|
||||
|
||||
# Model Settings
|
||||
self.model_size = model_size
|
||||
self.model_speeds = {
|
||||
'tiny': 10,
|
||||
'base': 7,
|
||||
'small': 4,
|
||||
'medium': 2,
|
||||
'large': 1
|
||||
}
|
||||
|
||||
# Progress tracking
|
||||
self.progress = None
|
||||
self.main_task = None
|
||||
|
||||
console.print(f"[bold green]⚡ Parallel-Modus aktiviert:[/bold green]")
|
||||
console.print(f" • Max Downloads: {self.max_downloads}")
|
||||
console.print(f" • Max Transkriptionen: {self.max_transcriptions}")
|
||||
console.print(f" • Whisper Model: {model_size}")
|
||||
|
||||
def load_cache(self):
|
||||
"""Lädt den Cache"""
|
||||
if self.cache_file.exists():
|
||||
with open(self.cache_file, 'r') as f:
|
||||
return json.load(f)
|
||||
return {}
|
||||
|
||||
def save_cache(self):
|
||||
"""Speichert den Cache"""
|
||||
with open(self.cache_file, 'w') as f:
|
||||
json.dump(self.cache, f, indent=2)
|
||||
|
||||
def get_video_hash(self, url):
|
||||
"""Erstellt einen Hash für die Video-URL"""
|
||||
return hashlib.md5(url.encode()).hexdigest()
|
||||
|
||||
def is_cached(self, url):
|
||||
"""Prüft ob Video bereits transkribiert wurde"""
|
||||
video_hash = self.get_video_hash(url)
|
||||
if video_hash in self.cache:
|
||||
cached_info = self.cache[video_hash]
|
||||
output_file = Path(cached_info['output_file'])
|
||||
if output_file.exists():
|
||||
return cached_info
|
||||
return None
|
||||
|
||||
def download_worker(self, job: VideoJob) -> Tuple[Optional[Path], Dict]:
|
||||
"""
|
||||
Worker-Funktion für Downloads
|
||||
Läuft in einem Thread
|
||||
"""
|
||||
try:
|
||||
with self.lock:
|
||||
job.status = "downloading"
|
||||
|
||||
ydl_opts = {
|
||||
'format': 'bestaudio/best',
|
||||
'postprocessors': [{
|
||||
'key': 'FFmpegExtractAudio',
|
||||
'preferredcodec': 'mp3',
|
||||
'preferredquality': '192',
|
||||
}],
|
||||
'outtmpl': str(self.temp_dir / f'%(id)s_%(title)s.%(ext)s'),
|
||||
'quiet': True,
|
||||
'no_warnings': True,
|
||||
}
|
||||
|
||||
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
||||
info = ydl.extract_info(job.url, download=True)
|
||||
|
||||
# Finde die heruntergeladene Datei
|
||||
video_id = info.get('id', '')
|
||||
audio_files = list(self.temp_dir.glob(f"{video_id}*.mp3"))
|
||||
|
||||
if not audio_files:
|
||||
raise Exception("Audio-Datei nicht gefunden")
|
||||
|
||||
audio_file = audio_files[0]
|
||||
|
||||
video_info = {
|
||||
'title': info.get('title', 'unknown'),
|
||||
'channel': info.get('uploader', 'unknown'),
|
||||
'duration': info.get('duration', 0),
|
||||
'url': job.url
|
||||
}
|
||||
|
||||
with self.lock:
|
||||
job.title = video_info['title']
|
||||
job.duration = video_info['duration']
|
||||
|
||||
return audio_file, video_info
|
||||
|
||||
except Exception as e:
|
||||
with self.lock:
|
||||
job.status = "failed"
|
||||
job.error = str(e)
|
||||
console.print(f"[red]❌ Download-Fehler für {job.url}: {e}[/red]")
|
||||
return None, {}
|
||||
|
||||
def transcribe_worker(self, model, audio_path: Path, job: VideoJob, video_info: Dict) -> Optional[str]:
|
||||
"""
|
||||
Worker-Funktion für Transkription
|
||||
Läuft in einem Thread mit eigenem Whisper-Model
|
||||
"""
|
||||
try:
|
||||
with self.lock:
|
||||
job.status = "transcribing"
|
||||
|
||||
# Transkribiere
|
||||
result = model.transcribe(
|
||||
str(audio_path),
|
||||
language=job.language,
|
||||
verbose=False,
|
||||
fp16=False # Für M1 Mac
|
||||
)
|
||||
|
||||
transcript = result['text']
|
||||
|
||||
# Speichere Transkript
|
||||
output_path = self.save_transcript(transcript, video_info, job.playlist_name)
|
||||
|
||||
# Update Cache
|
||||
video_hash = self.get_video_hash(job.url)
|
||||
self.cache[video_hash] = {
|
||||
'url': job.url,
|
||||
'title': video_info['title'],
|
||||
'output_file': str(output_path),
|
||||
'transcribed_at': datetime.now().isoformat(),
|
||||
'model': self.model_size,
|
||||
'playlist': job.playlist_name
|
||||
}
|
||||
self.save_cache()
|
||||
|
||||
# Lösche Audio-Datei
|
||||
try:
|
||||
audio_path.unlink()
|
||||
except:
|
||||
pass
|
||||
|
||||
with self.lock:
|
||||
job.status = "completed"
|
||||
job.output_path = str(output_path)
|
||||
|
||||
return str(output_path)
|
||||
|
||||
except Exception as e:
|
||||
with self.lock:
|
||||
job.status = "failed"
|
||||
job.error = str(e)
|
||||
console.print(f"[red]❌ Transkriptions-Fehler: {e}[/red]")
|
||||
return None
|
||||
|
||||
def save_transcript(self, text, video_info, playlist_name=None):
|
||||
"""Speichert Transkript"""
|
||||
base_dir = self.output_dir
|
||||
|
||||
if playlist_name:
|
||||
base_dir = base_dir / playlist_name.replace('/', '_')
|
||||
base_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
channel_dir = base_dir / video_info['channel'].replace('/', '_')
|
||||
channel_dir.mkdir(exist_ok=True)
|
||||
|
||||
safe_title = "".join(c for c in video_info['title'] if c.isalnum() or c in (' ', '-', '_'))[:100]
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{safe_title}_{timestamp}.txt"
|
||||
|
||||
filepath = channel_dir / filename
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(f"YouTube Transkription\n")
|
||||
f.write("=" * 50 + "\n\n")
|
||||
f.write(f"Titel: {video_info['title']}\n")
|
||||
f.write(f"Kanal: {video_info['channel']}\n")
|
||||
f.write(f"URL: {video_info['url']}\n")
|
||||
if playlist_name:
|
||||
f.write(f"Playlist: {playlist_name}\n")
|
||||
f.write(f"Transkribiert am: {datetime.now().strftime('%d.%m.%Y %H:%M:%S')}\n")
|
||||
f.write(f"Whisper Model: {self.model_size}\n")
|
||||
f.write("\n" + "=" * 50 + "\n\n")
|
||||
f.write("TRANSKRIPTION:\n\n")
|
||||
f.write(text)
|
||||
|
||||
return filepath
|
||||
|
||||
def process_pipeline(self, urls: List[str], playlist_name: Optional[str] = None, language: str = "de"):
|
||||
"""
|
||||
Haupt-Pipeline für parallele Verarbeitung
|
||||
"""
|
||||
# Filtere bereits transkribierte Videos
|
||||
jobs_to_process = []
|
||||
cached_count = 0
|
||||
|
||||
for url in urls:
|
||||
if self.is_cached(url):
|
||||
cached_count += 1
|
||||
else:
|
||||
job = VideoJob(url=url, playlist_name=playlist_name, language=language)
|
||||
self.jobs[url] = job
|
||||
jobs_to_process.append(job)
|
||||
|
||||
if not jobs_to_process:
|
||||
console.print("[green]✅ Alle Videos bereits transkribiert![/green]")
|
||||
return
|
||||
|
||||
# Status-Übersicht
|
||||
console.print(Panel(
|
||||
f"[bold]🚀 Starte parallele Verarbeitung[/bold]\n\n"
|
||||
f"📊 Gesamt: {len(urls)} Videos\n"
|
||||
f"✅ Gecached: {cached_count}\n"
|
||||
f"🆕 Zu verarbeiten: {len(jobs_to_process)}\n\n"
|
||||
f"⚡ Downloads: {self.max_downloads} parallel\n"
|
||||
f"🎙️ Transkriptionen: {self.max_transcriptions} parallel",
|
||||
border_style="cyan"
|
||||
))
|
||||
|
||||
# Lade Whisper-Modelle (eines pro Thread)
|
||||
console.print(f"\n[cyan]⏳ Lade {self.max_transcriptions}x Whisper {self.model_size} Modelle...[/cyan]")
|
||||
models = []
|
||||
for i in range(self.max_transcriptions):
|
||||
model = whisper.load_model(self.model_size)
|
||||
models.append(model)
|
||||
console.print(f" ✅ Model {i+1}/{self.max_transcriptions} geladen")
|
||||
|
||||
# Progress Bar
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
TextColumn("[progress.description]{task.description}"),
|
||||
BarColumn(),
|
||||
MofNCompleteColumn(),
|
||||
TimeElapsedColumn(),
|
||||
TimeRemainingColumn(),
|
||||
console=console
|
||||
) as progress:
|
||||
|
||||
main_task = progress.add_task(
|
||||
"[cyan]Verarbeite Videos...",
|
||||
total=len(jobs_to_process)
|
||||
)
|
||||
|
||||
# Futures für Downloads und Transkriptionen
|
||||
download_futures = {}
|
||||
transcribe_futures = {}
|
||||
model_pool = models.copy() # Pool verfügbarer Modelle
|
||||
|
||||
# Starte initiale Downloads
|
||||
for job in jobs_to_process[:self.max_downloads]:
|
||||
future = self.download_pool.submit(self.download_worker, job)
|
||||
download_futures[future] = job
|
||||
|
||||
remaining_jobs = jobs_to_process[self.max_downloads:]
|
||||
completed_count = 0
|
||||
|
||||
# Haupt-Loop
|
||||
while download_futures or transcribe_futures or remaining_jobs:
|
||||
|
||||
# Prüfe fertige Downloads
|
||||
for future in list(download_futures.keys()):
|
||||
if future.done():
|
||||
job = download_futures.pop(future)
|
||||
audio_path, video_info = future.result()
|
||||
|
||||
if audio_path and model_pool:
|
||||
# Starte Transkription wenn Model verfügbar
|
||||
model = model_pool.pop()
|
||||
trans_future = self.transcribe_pool.submit(
|
||||
self.transcribe_worker, model, audio_path, job, video_info
|
||||
)
|
||||
transcribe_futures[trans_future] = (job, model)
|
||||
|
||||
# Starte nächsten Download
|
||||
if remaining_jobs:
|
||||
next_job = remaining_jobs.pop(0)
|
||||
future = self.download_pool.submit(self.download_worker, next_job)
|
||||
download_futures[future] = next_job
|
||||
|
||||
# Prüfe fertige Transkriptionen
|
||||
for future in list(transcribe_futures.keys()):
|
||||
if future.done():
|
||||
job, model = transcribe_futures.pop(future)
|
||||
result = future.result()
|
||||
|
||||
# Model zurück in Pool
|
||||
model_pool.append(model)
|
||||
|
||||
if result:
|
||||
completed_count += 1
|
||||
progress.update(main_task, advance=1)
|
||||
console.print(f" ✅ {job.title[:50]}")
|
||||
else:
|
||||
console.print(f" ❌ Fehler bei: {job.url}")
|
||||
|
||||
# Kurze Pause für CPU
|
||||
time.sleep(0.1)
|
||||
|
||||
# Warte auf alle verbleibenden Tasks
|
||||
for future in as_completed(list(download_futures.keys()) + list(transcribe_futures.keys())):
|
||||
pass
|
||||
|
||||
# Zusammenfassung
|
||||
console.print("\n" + "=" * 60)
|
||||
console.print(f"[bold green]✅ Verarbeitung abgeschlossen![/bold green]")
|
||||
console.print(f"Erfolgreich: {completed_count}/{len(jobs_to_process)}")
|
||||
|
||||
# Zeige Fehler falls vorhanden
|
||||
failed_jobs = [j for j in jobs_to_process if j.status == "failed"]
|
||||
if failed_jobs:
|
||||
console.print(f"\n[red]Fehlerhafte Videos:[/red]")
|
||||
for job in failed_jobs:
|
||||
console.print(f" • {job.url}: {job.error}")
|
||||
|
||||
def process_playlist_file(self, playlist_path: Path, language: str = "de"):
|
||||
"""Verarbeitet eine Playlist-Datei"""
|
||||
urls = []
|
||||
with open(playlist_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#'):
|
||||
if 'youtube.com' in line or 'youtu.be' in line:
|
||||
urls.append(line)
|
||||
|
||||
if urls:
|
||||
playlist_name = playlist_path.stem
|
||||
self.process_pipeline(urls, playlist_name, language)
|
||||
else:
|
||||
console.print(f"[yellow]Keine URLs in {playlist_path}[/yellow]")
|
||||
|
||||
|
||||
def benchmark_parallel_vs_sequential():
|
||||
"""
|
||||
Benchmark-Funktion zum Vergleich
|
||||
"""
|
||||
console.print("\n[bold cyan]📊 Performance-Vergleich[/bold cyan]")
|
||||
|
||||
table = Table(title="Geschwindigkeitsvergleich")
|
||||
table.add_column("Modus", style="cyan")
|
||||
table.add_column("10 Videos (je 5 Min)", style="white")
|
||||
table.add_column("Speedup", style="green")
|
||||
|
||||
table.add_row(
|
||||
"Sequenziell (v3)",
|
||||
"~50 Minuten",
|
||||
"1x"
|
||||
)
|
||||
table.add_row(
|
||||
"Parallel 2 Downloads",
|
||||
"~25 Minuten",
|
||||
"2x"
|
||||
)
|
||||
table.add_row(
|
||||
"Parallel 3 Downloads + 2 Transkriptionen",
|
||||
"~15 Minuten",
|
||||
"3.3x"
|
||||
)
|
||||
|
||||
console.print(table)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='YouTube Transcriber v4.0 - PARALLEL EDITION'
|
||||
)
|
||||
parser.add_argument(
|
||||
'command',
|
||||
nargs='?',
|
||||
choices=['process', 'benchmark'],
|
||||
default='process',
|
||||
help='Befehl: process oder benchmark'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--playlist',
|
||||
help='Playlist-Datei'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--urls',
|
||||
nargs='+',
|
||||
help='Direkte URL-Liste'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--model',
|
||||
default='base',
|
||||
choices=['tiny', 'base', 'small', 'medium', 'large'],
|
||||
help='Whisper Model'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--language',
|
||||
default='de',
|
||||
help='Sprache'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--max-downloads',
|
||||
type=int,
|
||||
default=3,
|
||||
help='Max parallele Downloads (default: 3)'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--max-transcriptions',
|
||||
type=int,
|
||||
default=2,
|
||||
help='Max parallele Transkriptionen (default: 2)'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Zeige Logo
|
||||
console.print(LOGO)
|
||||
|
||||
if args.command == 'benchmark':
|
||||
benchmark_parallel_vs_sequential()
|
||||
return
|
||||
|
||||
# Initialisiere Parallel-Transcriber
|
||||
transcriber = ParallelTranscriber(
|
||||
model_size=args.model,
|
||||
max_downloads=args.max_downloads,
|
||||
max_transcriptions=args.max_transcriptions
|
||||
)
|
||||
|
||||
if args.playlist:
|
||||
# Verarbeite Playlist-Datei
|
||||
playlist_path = Path(args.playlist)
|
||||
if playlist_path.exists():
|
||||
transcriber.process_playlist_file(playlist_path, args.language)
|
||||
else:
|
||||
console.print(f"[red]Playlist nicht gefunden: {args.playlist}[/red]")
|
||||
|
||||
elif args.urls:
|
||||
# Verarbeite direkte URLs
|
||||
transcriber.process_pipeline(args.urls, language=args.language)
|
||||
|
||||
else:
|
||||
console.print("[yellow]Bitte URLs oder Playlist angeben![/yellow]")
|
||||
console.print("\nBeispiele:")
|
||||
console.print(" python3 transcriber_v4_parallel.py --urls URL1 URL2 URL3")
|
||||
console.print(" python3 transcriber_v4_parallel.py --playlist playlists/tech/python.txt")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
{
|
||||
"name": "wisekeep",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Wisekeep - AI-powered wisdom extraction from video content",
|
||||
"scripts": {
|
||||
"dev": "turbo run dev",
|
||||
"dev:backend": "pnpm --filter @wisekeep/backend dev",
|
||||
"dev:web": "pnpm --filter @wisekeep/web dev",
|
||||
"dev:landing": "pnpm --filter @wisekeep/landing dev",
|
||||
"dev:mobile": "pnpm --filter @wisekeep/mobile dev",
|
||||
"build": "turbo run build",
|
||||
"lint": "turbo run lint",
|
||||
"type-check": "turbo run type-check",
|
||||
"clean": "turbo run clean"
|
||||
},
|
||||
"devDependencies": {
|
||||
"turbo": "^1.13.4",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"name": "@wisekeep/shared-types",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
// Transcription Job Types
|
||||
export type JobStatus =
|
||||
| 'pending'
|
||||
| 'downloading'
|
||||
| 'transcribing'
|
||||
| 'completed'
|
||||
| 'failed'
|
||||
| 'cancelled';
|
||||
|
||||
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 VideoInfo {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
duration: number;
|
||||
thumbnail?: string;
|
||||
}
|
||||
|
||||
export interface TranscriptionJob {
|
||||
id: string;
|
||||
url: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
language: string;
|
||||
provider: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
videoInfo?: VideoInfo;
|
||||
transcriptPath?: string;
|
||||
transcriptText?: string;
|
||||
error?: string;
|
||||
createdAt: string;
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface TranscribeRequest {
|
||||
url: string;
|
||||
language?: string;
|
||||
provider?: WhisperProvider;
|
||||
model?: WhisperModel;
|
||||
}
|
||||
|
||||
export interface TranscriptionStats {
|
||||
totalTranscripts: number;
|
||||
totalSizeMB: number;
|
||||
activeJobs: number;
|
||||
completedJobs: number;
|
||||
failedJobs: number;
|
||||
}
|
||||
|
||||
// WebSocket Event Types
|
||||
export interface JobUpdateEvent {
|
||||
type: 'job_update';
|
||||
jobId: string;
|
||||
status: JobStatus;
|
||||
progress: number;
|
||||
videoInfo?: VideoInfo;
|
||||
}
|
||||
|
||||
export interface JobCompleteEvent {
|
||||
type: 'job_complete';
|
||||
jobId: string;
|
||||
status: 'completed';
|
||||
transcriptPath: string;
|
||||
}
|
||||
|
||||
export interface JobErrorEvent {
|
||||
type: 'job_error';
|
||||
jobId: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export type WebSocketEvent = JobUpdateEvent | JobCompleteEvent | JobErrorEvent;
|
||||
|
||||
// Playlist Types
|
||||
export interface Playlist {
|
||||
name: string;
|
||||
category: string;
|
||||
urls: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface PlaylistSummary {
|
||||
category: string;
|
||||
name: string;
|
||||
urlCount: number;
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
53
apps/wisekeep/CLAUDE.md
Normal file
53
apps/wisekeep/CLAUDE.md
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
# Wisekeep — AI Wisdom Extraction from Video
|
||||
|
||||
## Architecture
|
||||
|
||||
Local-first for transcripts/playlists, Hono/Bun server for Groq Whisper transcription.
|
||||
|
||||
```
|
||||
Browser → IndexedDB (Transcripts, Playlists)
|
||||
↕ sync
|
||||
mana-sync → PostgreSQL
|
||||
|
||||
Browser → Hono Server → yt-dlp (download) → Groq Whisper (transcribe)
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/wisekeep/
|
||||
├── apps/
|
||||
│ ├── web/ # SvelteKit web app (local-first)
|
||||
│ ├── server/ # Hono/Bun (transcription via Groq)
|
||||
│ └── landing/ # Astro content site (curated talks)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm dev:wisekeep:web # SvelteKit dev server
|
||||
pnpm dev:wisekeep:server # Hono/Bun server (port 3072)
|
||||
pnpm dev:wisekeep:landing # Landing page
|
||||
pnpm dev:wisekeep:local # Web + Sync + Server (no auth)
|
||||
pnpm dev:wisekeep:full # Everything incl. auth
|
||||
```
|
||||
|
||||
## Server Routes
|
||||
|
||||
| Route | Auth | Description |
|
||||
|-------|------|-------------|
|
||||
| `GET /health` | No | Health check |
|
||||
| `POST /api/v1/transcribe` | JWT | Transcribe YouTube URL via Groq |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- `yt-dlp` installed (`brew install yt-dlp`)
|
||||
- `GROQ_API_KEY` env variable set
|
||||
|
||||
## Local-First Collections
|
||||
|
||||
| Collection | Purpose |
|
||||
|-----------|---------|
|
||||
| `transcripts` | Video transcriptions (title, channel, transcript text) |
|
||||
| `playlists` | Organized collections of transcripts |
|
||||
|
|
@ -4,8 +4,5 @@ import tailwind from '@astrojs/tailwind';
|
|||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
integrations: [
|
||||
solidJs(),
|
||||
tailwind()
|
||||
]
|
||||
});
|
||||
integrations: [solidJs(), tailwind()],
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue