managarten/apps/transcriber/apps/landing/src/components/admin/Dashboard.tsx
Till-JS 4b08c41547 feat(transcriber): Add YouTube transcriber app to monorepo
Integrate new transcriber application for AI-powered YouTube video
transcription with full monorepo structure and Groq Whisper API support.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-27 14:21:49 +01:00

225 lines
No EOL
7.2 KiB
TypeScript

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>
);
}