refactor(ui): Dashboard, DeviceDiscovery & Settings auf Cinematic Cinema OS umgestellt
Ampel / ampel (push) Successful in 27s

This commit is contained in:
Hitonabi
2026-07-24 16:04:50 +02:00
parent 90b708302e
commit bd00d4c728
4 changed files with 907 additions and 1230 deletions
+193 -255
View File
@@ -1,12 +1,16 @@
import { useEffect, useState } from 'react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
import ConfirmDialog from '../components/ConfirmDialog'
import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import { ENCODER_BADGES } from '../lib/design'
interface Job {
id: string
@@ -29,105 +33,19 @@ async function fetchJobs(): Promise<Job[]> {
}
}
function StatusBadge({ status }: { status: string }) {
const { theme } = useDarkMode()
const styles = theme === 'dark'
? {
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20',
canceling: 'bg-orange-900/30 text-orange-400 ring-orange-700/20',
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
}
: {
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20',
canceling: 'bg-orange-100 text-orange-700 ring-orange-600/20',
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
}
const labels = {
pending: 'Wartend',
processing: 'Rippen',
transcoding: 'Komprimieren',
canceling: 'Wird abgebrochen…',
completed: 'Fertig',
failed: 'Fehler',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ring-1 ring-inset ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-700 ring-gray-600/10'}`}>
{labels[status as keyof typeof labels] || status}
</span>
)
}
function TypeBadge({ type }: { type: string }) {
const { theme } = useDarkMode()
// 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray,
// aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein.
const colors: Record<string, string> = theme === 'dark'
? {
cd: 'bg-blue-900/30 text-blue-400',
dvd: 'bg-purple-900/30 text-purple-400',
bluray: 'bg-pink-900/30 text-pink-400',
uhd: 'bg-amber-900/30 text-amber-400',
}
: {
cd: 'bg-blue-100 text-blue-600',
dvd: 'bg-purple-100 text-purple-600',
bluray: 'bg-pink-100 text-pink-600',
uhd: 'bg-amber-100 text-amber-700',
}
const labels: Record<string, string> = {
cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD',
}
const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700'
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
<Disc size={14} />
{labels[type] || type.toUpperCase()}
</span>
)
}
function ProgressBar({ progress }: { progress: number }) {
const { theme } = useDarkMode()
const bgColors = theme === 'dark'
? {
empty: 'bg-slate-700',
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
done: 'bg-emerald-500',
}
: {
empty: 'bg-slate-200',
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
done: 'bg-emerald-500',
}
const textColors = theme === 'dark'
? {
label: 'text-slate-400',
value: 'text-slate-200',
}
: {
label: 'text-slate-500',
value: 'text-slate-900',
}
return (
<div className="w-32">
<div className="flex items-center justify-between mb-1">
<span className={`text-xs font-medium ${textColors.label}`}>Fortschritt</span>
<span className={`text-xs font-medium ${textColors.value}`}>{progress}%</span>
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
<span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
</div>
<div className={`w-full ${bgColors.empty} rounded-full h-2 overflow-hidden`}>
<div className="w-full bg-slate-200 dark:bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-300/30 dark:border-slate-700/30">
<div
className={`h-2 rounded-full transition-all duration-500 ease-out ${
progress === 100 ? bgColors.done : bgColors.indicator
progress === 100
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
: 'bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-600 shadow-sm shadow-indigo-500/50'
}`}
style={{ width: `${progress}%` }}
/>
@@ -141,13 +59,6 @@ interface WorkerInfo {
encoders: string[]
}
const ENCODER_KURZ: Record<string, string> = {
'cpu-x264': 'H.264 (CPU)',
'cpu-x265': 'H.265 (CPU)',
'vaapi': 'VAAPI ⚡',
'nvenc': 'NVENC ⚡',
}
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([])
@@ -155,7 +66,6 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast()
const jobLoeschen = async (job: Job) => {
@@ -207,153 +117,219 @@ export default function Dashboard() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
</div>
)
}
return (
<div className="space-y-8 transition-colors duration-300">
{/* Header */}
<div className="flex flex-col gap-2 transition-colors duration-300">
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Dashboard</h1>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
</div>
<div className="space-y-8">
<PageHeader
title="Dashboard"
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
/>
{/* System-Leiste: Encoder + aktiver Job auf einen Blick */}
<div className={`flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border px-5 py-3 text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-2">
<Activity size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span>
{workers.length === 0 ? (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Worker</span>
) : (
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}>
{ENCODER_KURZ[e] || e}
{/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
{aktiverJob && (
<Card glow className="relative overflow-hidden border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 p-6 shadow-2xl">
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="relative flex-shrink-0">
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 flex items-center justify-center p-1 shadow-xl shadow-amber-500/20 animate-disc-spin">
<div className="w-full h-full rounded-full border-4 border-dashed border-white/40 flex items-center justify-center">
<Disc size={36} className="text-white" />
</div>
</div>
<span className="absolute -top-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-amber-500"></span>
</span>
))
)}
</div>
<div className="flex items-center gap-2">
<Clock size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span>
{aktiverJob ? (
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span>
) : (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span>
)}
</div>
{plaetze.length > 0 && (
<div className="flex items-center gap-2">
<HardDrive size={16} className="text-indigo-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span>
{plaetze.map(p => (
<span
key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
className={`text-xs px-2 py-0.5 rounded ${p.frei_gb < 60 ? 'bg-amber-600/20 text-amber-500 font-medium' : theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}
</div>
<div className="flex-1 min-w-0 text-center md:text-left space-y-2">
<div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
</span>
<TypeBadge type={aktiverJob.type} />
</div>
<h2 className="text-2xl font-bold text-slate-100 truncate">
{aktiverJob.title || 'Aktiver Job'}
</h2>
<p className="text-xs text-slate-400">
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
</p>
<div className="pt-2">
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
</div>
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
<div
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
style={{ width: `${aktiverJob.progress}%` }}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
</Button>
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
>
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span>
))}
Abbrechen
</Button>
</div>
</div>
)}
</div>
</Card>
)}
{/* Laufwerke + erkannte Disc */}
{/* System-Telemetrie Leiste */}
<Card>
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
<div className="flex items-center gap-2.5">
<Activity size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Encoder:</span>
{workers.length === 0 ? (
<span className="text-slate-400 dark:text-slate-500 italic">kein Worker</span>
) : (
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu')
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_BADGES[e]?.label || e}
</span>
))
)}
</div>
<div className="flex items-center gap-2.5">
<Clock size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
{aktiverJob ? (
<span className="text-slate-900 dark:text-slate-200 font-medium">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span>
) : (
<span className="text-slate-400 dark:text-slate-500 italic">kein Job</span>
)}
</div>
{plaetze.length > 0 && (
<div className="flex items-center gap-2.5 ml-auto">
<HardDrive size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
{plaetze.map(p => (
<span
key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${
p.frei_gb < 60
? 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30 font-semibold'
: 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
}`}
>
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span>
))}
</div>
)}
</CardContent>
</Card>
{/* Laufwerke & Erkennung */}
<DeviceDiscovery />
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={Clock}
label="Warteschlange"
value={stats.pending.toString()}
color="amber"
darkMode={theme === 'dark'}
badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20"
/>
<StatCard
icon={Activity}
label="Bearbeitung"
value={stats.processing.toString()}
color="blue"
darkMode={theme === 'dark'}
badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20"
/>
<StatCard
icon={CheckCircle}
label="Fertig"
value={stats.completed.toString()}
color="emerald"
darkMode={theme === 'dark'}
badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20"
/>
<StatCard
icon={AlertCircle}
label="Fehler"
value={stats.failed.toString()}
color="rose"
darkMode={theme === 'dark'}
badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20"
/>
</div>
{/* Recent Jobs */}
{/* Schnellaktionen + doppelte Geräte-Sektion sind raus (23.07.): alle
vier Knöpfe und der zweite Details-Button waren funktionslos —
die echte Geräte-Steuerung lebt in DeviceDiscovery oben. */}
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
{/* Recent Jobs Table */}
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Neueste Jobs</CardTitle>
<div className="flex items-center gap-3">
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span>
<span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span>
{jobs.length > 0 && (
<a
href="/api/jobs/export"
download
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
<FileSpreadsheet size={13} />
CSV
</a>
)}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<button
<Button
variant="secondary"
size="sm"
onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
>
<Trash2 size={13} />
Erledigte aufräumen
</button>
</Button>
)}
</div>
</div>
<div className="overflow-x-auto transition-colors duration-300">
<table className="w-full transition-colors duration-300">
<thead className={theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}>
</CardHeader>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
<tr>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Titel</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Status</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Gerät</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fortschritt</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Startzeit</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Aktion</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Typ</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Titel</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Status</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Gerät</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Fortschritt</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Startzeit</th>
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Aktion</th>
</tr>
</thead>
<tbody className={`divide-y ${theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200'}`}>
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
{jobs.length === 0 ? (
<tr>
<td colSpan={7} className="px-6 py-12 text-center transition-colors duration-300">
<div className={`mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-400'}`}>Noch keine Jobs</div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
<td colSpan={7} className="px-6 py-12 text-center">
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
</td>
</tr>
) : (
jobs.slice(0, 10).map(job => (
<tr key={job.id} className={`transition-colors duration-300 ${theme === 'dark' ? 'hover:bg-slate-700/40' : 'hover:bg-slate-50'}`}>
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
@@ -361,7 +337,7 @@ export default function Dashboard() {
<button
onClick={() => setDetailJobId(job.id)}
title="Details zu diesem Titel anzeigen"
className={`text-sm font-medium text-left hover:underline transition-colors ${theme === 'dark' ? 'text-slate-100 hover:text-indigo-300' : 'text-slate-900 hover:text-indigo-600'}`}
className="text-sm font-medium text-left hover:underline text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors"
>
{job.title || 'Unbekannt'}
</button>
@@ -369,58 +345,59 @@ export default function Dashboard() {
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} />
</td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap">
{job.status === 'completed' && (
<button
<Button
variant="secondary"
size="sm"
onClick={() => setDetailJobId(job.id)}
title="Fertige Dateien herunterladen (öffnet die Job-Details)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-emerald-300 hover:bg-slate-600' : 'bg-slate-100 text-emerald-700 hover:bg-slate-200'}`}
title="Fertige Dateien herunterladen (öffnet Job-Details)"
>
<Download size={13} />
<Download size={13} className="text-emerald-500" />
Download
</button>
</Button>
)}
{/* Nur wenn Rohdaten wirklich daliegen — an einem Job,
der nie gerippt hat, war der Knopf Unsinn */}
{job.status === 'failed' && job.can_retry && (
<button
<Button
variant="secondary"
size="sm"
onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
}}
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`}
title="Kompression neu anstoßen"
>
Neu komprimieren
</button>
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
title="Job aus der Liste entfernen — fertige Dateien bleiben liegen"
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`}
title="Job aus der Liste entfernen"
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<button
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
title="Job abbrechen (Rohdaten bleiben erhalten)"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-rose-300 hover:bg-slate-600' : 'bg-slate-100 text-rose-700 hover:bg-slate-200'}`}
title="Job abbrechen"
>
Abbrechen
</button>
</Button>
)}
</td>
</tr>
@@ -429,12 +406,10 @@ export default function Dashboard() {
</tbody>
</table>
</div>
</div>
</Card>
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
<LiveLogSection />
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
<ConfirmDialog
@@ -450,55 +425,18 @@ export default function Dashboard() {
)
}
function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) {
const { theme } = useDarkMode()
const bgColors = darkMode
? {
card: 'bg-slate-800 border-slate-700',
iconBg: {
amber: 'bg-amber-700',
blue: 'bg-blue-700',
emerald: 'bg-emerald-700',
rose: 'bg-rose-700',
},
iconText: {
amber: 'text-amber-300',
blue: 'text-blue-300',
emerald: 'text-emerald-300',
rose: 'text-rose-300',
},
textLabel: 'text-slate-400',
textValue: 'text-slate-200',
}
: {
card: 'bg-white border-slate-200',
iconBg: {
amber: 'bg-amber-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
rose: 'bg-rose-500',
},
iconText: {
amber: 'text-amber-600',
blue: 'text-blue-600',
emerald: 'text-emerald-600',
rose: 'text-rose-600',
},
textLabel: 'text-slate-500',
textValue: 'text-slate-900',
}
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
return (
<div className={`${bgColors.card} rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow`}>
<div className="flex items-center gap-4">
<div className={`${bgColors.iconBg[color as keyof typeof bgColors.iconBg]} p-3 rounded-lg`}>
<Icon className={`${bgColors.iconText[color as keyof typeof bgColors.iconText]} w-6 h-6`} />
<Card>
<CardContent className="p-5 flex items-center gap-4">
<div className={`p-3 rounded-xl border ${badgeStyle}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className={`text-sm font-medium ${bgColors.textLabel}`}>{label}</p>
<p className={`text-3xl font-bold ${bgColors.textValue}`}>{value}</p>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
<p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
File diff suppressed because it is too large Load Diff