refactor(ui): Current Job, Ripping Queue und Job-Historie in ein einziges Master-Panel bündig unter Laufwerke zusammengeführt
Ampel / ampel (push) Successful in 27s

This commit is contained in:
Hitonabi
2026-07-24 16:43:10 +02:00
parent e0864828c2
commit bf80f9dfd4
+258 -280
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet } from 'lucide-react'
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, Play, Layers, Clock, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
@@ -60,7 +60,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const [tableFilter, setTableFilter] = useState<'all' | 'active' | 'completed' | 'failed'>('all')
const [activeTab, setActiveTab] = useState<'all' | 'active' | 'queue' | 'completed' | 'failed'>('all')
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const { toast } = useToast()
@@ -111,6 +111,7 @@ export default function Dashboard() {
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const queueJobs = jobs.filter(j => j.status === 'pending')
const completedJobs = jobs.filter(j => j.status === 'completed')
const failedJobs = jobs.filter(j => j.status === 'failed')
const mainPlatz = systemInfo?.plaetze?.[0]
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
@@ -125,10 +126,11 @@ export default function Dashboard() {
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
}).join(' ')
const filteredTableJobs = jobs.filter(j => {
if (tableFilter === 'active') return j.status === 'processing' || j.status === 'transcoding' || j.status === 'pending'
if (tableFilter === 'completed') return j.status === 'completed'
if (tableFilter === 'failed') return j.status === 'failed'
const filteredJobs = jobs.filter(j => {
if (activeTab === 'active') return j.status === 'processing' || j.status === 'transcoding'
if (activeTab === 'queue') return j.status === 'pending'
if (activeTab === 'completed') return j.status === 'completed'
if (activeTab === 'failed') return j.status === 'failed'
return true
})
@@ -143,146 +145,23 @@ export default function Dashboard() {
return (
<div className="space-y-6">
{/* 1. GANZ OBEN: Laufwerke & Erkannte Disc im Laufwerk */}
{/* 1. GANZ OBEN: Laufwerke & Erkannte Disc im Laufwerk (Akira Auto Pre-Scan Banner) */}
<DeviceDiscovery />
{/* 2. MAIN 2-COLUMN GRID LAYOUT (BÜNDIGER UNTERER ABSCHLUSS ZWISCHEN LINKS UND RECHTS) */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-stretch">
{/* 2. SYSTEM STATUS & ZULETZT GERIPPT HIGHLIGHT LEISTE */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COLUMN (lg:col-span-7) - flex flex-col justify-between fuer buendigen Abschluss */}
<div className="lg:col-span-7 flex flex-col justify-between space-y-6">
{/* Current Job Card */}
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-xl">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-amber-400 flex items-center gap-2">
<Disc size={18} className={aktiverJob ? 'text-amber-400 animate-disc-spin' : 'text-slate-500'} />
Current Job
</CardTitle>
</CardHeader>
<CardContent className="p-5">
{aktiverJob ? (
<div className="flex items-start gap-4">
{posterUrl(aktiverJob.meta) ? (
<img
src={posterUrl(aktiverJob.meta)!}
alt={aktiverJob.title || ''}
className="w-20 h-28 rounded-xl object-cover shadow-md border border-slate-700/80 flex-shrink-0"
/>
) : (
<div className="w-20 h-28 rounded-xl bg-gradient-to-br from-slate-800 to-slate-900 border border-slate-700 flex items-center justify-center flex-shrink-0">
<Disc size={32} className="text-amber-400" />
</div>
)}
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-bold text-slate-100 truncate">
{aktiverJob.title || 'Unbekannter Titel'}
</h3>
<TypeBadge type={aktiverJob.type} />
</div>
<div className="space-y-1">
<div className="flex items-center justify-between text-xs text-slate-400 font-mono">
<span>Status: {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Extraktion'}</span>
<span className="text-amber-400 font-bold">{aktiverJob.progress}%</span>
</div>
<div className="w-full h-2.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-amber-500 to-amber-400 rounded-full transition-all duration-500 shadow-[0_0_10px_rgba(245,158,11,0.5)]"
style={{ width: `${aktiverJob.progress}%` }}
/>
</div>
</div>
<div className="text-xs text-slate-400 space-y-0.5 font-mono pt-1">
<p>Status: {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless Extraktion'}</p>
<p>Disc typ: <strong className="text-amber-300">{aktiverJob.type.toUpperCase()}</strong></p>
<p>Gerät: {aktiverJob.device} · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</p>
</div>
</div>
</div>
) : (
<div className="text-center py-6 text-slate-400 text-sm">
Kein aktiver Job Laufwerk ist bereit für eine neue Disc
</div>
)}
</CardContent>
</Card>
{/* Ripping Queue Card (flex-1 flex flex-col justify-between fuer exakt buendigen Abschluss mit Recently Ripped) */}
<Card className="border border-slate-800 bg-[#0f1422] flex-1 flex flex-col justify-between">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200">Ripping Queue ({queueJobs.length})</CardTitle>
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col justify-between">
{queueJobs.length > 0 ? (
<div className="space-y-2.5">
{queueJobs.map((j, idx) => (
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/80 border border-slate-800 text-sm">
<div className="flex items-center gap-3 min-w-0">
{posterUrl(j.meta) ? (
<img src={posterUrl(j.meta)!} alt="" className="w-8 h-10 rounded object-cover flex-shrink-0" />
) : (
<div className="w-8 h-10 rounded bg-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
<Disc size={16} />
</div>
)}
<span className="font-mono text-amber-400 font-bold flex-shrink-0">{idx + 2}.</span>
<span className="font-semibold text-slate-100 truncate">{j.title || 'Unbekannt'}</span>
<TypeBadge type={j.type} />
</div>
<span className="text-xs text-slate-400 font-mono px-2 py-1 rounded bg-slate-800 border border-slate-700 flex-shrink-0">
Queue {idx + 1}
</span>
</div>
))}
</div>
) : (
/* SC1 Styled Queue List slots (buendiger voller Abschluss) */
<div className="space-y-3 my-auto py-2">
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-900/40 border border-slate-800/60 text-sm">
<div className="flex items-center gap-3">
<span className="font-mono text-amber-400/80 font-bold">2.</span>
<span className="font-medium text-slate-400">Bereit für nächste Disc...</span>
</div>
<span className="text-xs text-slate-500 font-mono">Queue 1</span>
</div>
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-900/40 border border-slate-800/60 text-sm">
<div className="flex items-center gap-3">
<span className="font-mono text-amber-400/80 font-bold">3.</span>
<span className="font-medium text-slate-400">Bereit für nächste Disc...</span>
</div>
<span className="text-xs text-slate-500 font-mono">Queue 2</span>
</div>
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-900/40 border border-slate-800/60 text-sm">
<div className="flex items-center gap-3">
<span className="font-mono text-amber-400/80 font-bold">4.</span>
<span className="font-medium text-slate-400">Bereit für nächste Disc...</span>
</div>
<span className="text-xs text-slate-500 font-mono">Queue 3</span>
</div>
</div>
)}
</CardContent>
</Card>
</div>
{/* RIGHT COLUMN (lg:col-span-5) - flex flex-col justify-between */}
<div className="lg:col-span-5 flex flex-col justify-between space-y-6">
{/* Server Status Card */}
<Card className="border border-slate-800 bg-[#0f1422]">
{/* Left: Server Status Card */}
<div className="lg:col-span-7">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<CardHeader className="border-b border-slate-800">
<CardTitle className="text-slate-200 flex items-center gap-2">
<Server size={18} className="text-amber-400" />
Server Status (Echte Live-Daten)
</CardTitle>
</CardHeader>
<CardContent className="p-5">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
<CardContent className="p-5 flex-1 flex items-center">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6 w-full">
<div className="space-y-4 flex-1 w-full">
<div>
<div className="flex items-center justify-between text-xs font-mono mb-1">
@@ -321,7 +200,7 @@ export default function Dashboard() {
</div>
</div>
<div className="w-full sm:w-36 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<div className="w-full sm:w-40 h-24 flex-shrink-0 flex items-end justify-center pt-2">
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
<path
d={`${sparklinePath} L 100 50 L 0 50 Z`}
@@ -333,24 +212,26 @@ export default function Dashboard() {
</div>
</CardContent>
</Card>
</div>
{/* Recently Ripped Card */}
<Card className="border border-slate-800 bg-[#0f1422] flex-1 flex flex-col justify-between">
{/* Right: Recently Ripped Posters */}
<div className="lg:col-span-5">
<Card className="border border-slate-800 bg-[#0f1422] h-full flex flex-col justify-between">
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
<CardTitle className="text-slate-200">Recently Ripped ({completedJobs.length})</CardTitle>
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
{completedJobs.length > 0 && (
<Button variant="ghost" size="sm" onClick={() => setAufraeumenOffen(true)}>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</CardHeader>
<CardContent className="p-4 flex-1 flex flex-col justify-between">
<CardContent className="p-4 flex-1 flex flex-col justify-center">
{completedJobs.length === 0 ? (
<div className="text-center py-8 text-slate-500 text-xs font-mono">
<div className="text-center py-6 text-slate-500 text-xs font-mono">
Noch keine gerippten Medien in der Datenbank
</div>
) : (
<div className="grid grid-cols-3 gap-3 my-auto">
<div className="grid grid-cols-3 gap-3">
{completedJobs.slice(0, 3).map((item) => {
const poster = posterUrl(item.meta)
return (
@@ -385,34 +266,81 @@ export default function Dashboard() {
)}
</CardContent>
</Card>
</div>
</div>
{/* 3. VOLLSTÄNDIGE JOB-TABELLE & MEDIEN-BIBLIOTHEK */}
<Card className="border border-slate-800 bg-[#0f1422] overflow-hidden">
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4">
{/* 3. DAS EINE ZUSAMMENGEFASSTE MASTER-PANEL: "Ripping Operations & Medien-Manager" */}
<Card className="border border-amber-500/30 bg-[#0f1422] shadow-2xl overflow-hidden">
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4 py-4">
<div className="flex items-center gap-3">
<CardTitle className="text-slate-100">Job-Historie &amp; Medien-Bibliothek</CardTitle>
<span className="text-xs font-mono text-slate-400">({filteredTableJobs.length} Einträge)</span>
<CardTitle className="text-amber-400 flex items-center gap-2 text-xl">
<Layers size={22} className="text-amber-400" />
Ripping Operations &amp; Medien-Manager
</CardTitle>
<span className="text-xs font-mono text-slate-400">({jobs.length} Jobs gesamt)</span>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Master Filter Tabs */}
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
<button
onClick={() => setActiveTab('all')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'all'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Alle ({jobs.length})</span>
</button>
<button
onClick={() => setActiveTab('active')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'active'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
{aktiverJob && <span className="w-2 h-2 rounded-full bg-amber-400 animate-ping" />}
<span>Aktiv ({aktiverJob ? 1 : 0})</span>
</button>
<button
onClick={() => setActiveTab('queue')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'queue'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Warteschlange ({queueJobs.length})</span>
</button>
<button
onClick={() => setActiveTab('completed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'completed'
? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<span>Fertig ({completedJobs.length})</span>
</button>
{failedJobs.length > 0 && (
<button
key={f}
onClick={() => setTableFilter(f)}
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
tableFilter === f
? 'bg-amber-500 text-slate-950 font-bold shadow-sm'
: 'text-slate-400 hover:text-slate-200'
onClick={() => setActiveTab('failed')}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 ${
activeTab === 'failed'
? 'bg-rose-500 text-white shadow-md font-bold'
: 'text-rose-400 hover:text-rose-200'
}`}
>
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktiv' : f === 'completed' ? 'Fertig' : 'Fehler'}
<span>Fehler ({failedJobs.length})</span>
</button>
))}
)}
</div>
{jobs.length > 0 && (
@@ -422,138 +350,188 @@ export default function Dashboard() {
title="Job-Historie als CSV herunterladen"
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-700 bg-slate-800/80 text-slate-300 hover:bg-slate-700 transition-colors"
>
<FileSpreadsheet size={13} />
CSV Export
<FileSpreadsheet size={14} />
CSV
</a>
)}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<Button
variant="secondary"
size="sm"
onClick={() => setAufraeumenOffen(true)}
>
{completedJobs.length > 0 && (
<Button variant="secondary" size="sm" onClick={() => setAufraeumenOffen(true)}>
<Trash2 size={13} /> Aufräumen
</Button>
)}
</div>
</CardHeader>
{filteredTableJobs.length === 0 ? (
<div className="text-center py-12 text-slate-500 text-xs font-mono">
Keine Jobs für diesen Filter vorhanden.
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-950/80 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
<tr>
<th className="px-6 py-3.5">Typ</th>
<th className="px-6 py-3.5">Titel &amp; Beschreibung</th>
<th className="px-6 py-3.5">Status</th>
<th className="px-6 py-3.5">Laufwerk</th>
<th className="px-6 py-3.5">Fortschritt</th>
<th className="px-6 py-3.5">Startzeit</th>
<th className="px-6 py-3.5 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80 text-sm">
{filteredTableJobs.map(job => {
const poster = posterUrl(job.meta)
return (
<tr key={job.id} className="transition-colors hover:bg-slate-900/50">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{poster ? (
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80" />
) : (
<div className="w-10 h-14 rounded-lg bg-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
<Film size={18} />
<CardContent className="p-0">
{/* Top Banner inside Master Panel when a Job is Ripping Live */}
{aktiverJob && (activeTab === 'all' || activeTab === 'active') && (
<div className="p-5 border-b border-amber-500/30 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950">
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-4">
{posterUrl(aktiverJob.meta) ? (
<img src={posterUrl(aktiverJob.meta)!} alt="" className="w-16 h-24 rounded-xl object-cover border border-amber-500/40 shadow-lg" />
) : (
<div className="w-16 h-24 rounded-xl bg-slate-900 border border-slate-800 flex items-center justify-center">
<Disc size={32} className="text-amber-400 animate-disc-spin" />
</div>
)}
<div className="space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase bg-amber-500 text-slate-950">
AKTIVER RIP IN ARBEIT
</span>
<TypeBadge type={aktiverJob.type} />
<StatusBadge status={aktiverJob.status} />
</div>
<h3 className="text-xl font-bold text-slate-100">{aktiverJob.title || 'Unbekannter Titel'}</h3>
<p className="text-xs text-slate-400 font-mono">
Laufwerk: {aktiverJob.device} · {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless'}
</p>
</div>
</div>
<div className="flex items-center gap-4 w-full sm:w-auto justify-end">
<div className="w-36 space-y-1 text-right">
<div className="text-xs font-mono text-amber-400 font-bold">{aktiverJob.progress}%</div>
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
<div className="h-full bg-amber-500 rounded-full transition-all duration-300" style={{ width: `${aktiverJob.progress}%` }} />
</div>
</div>
<Button variant="amber" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
</Button>
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}>
Abbrechen
</Button>
</div>
</div>
</div>
)}
{/* Master Unified Table */}
{filteredJobs.length === 0 ? (
<div className="text-center py-16">
<div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-900 text-slate-500 border border-slate-800">
<Disc size={32} />
</div>
<p className="text-sm font-mono text-slate-400">Keine Jobs in diesem Tab vorhanden</p>
<p className="text-xs text-slate-500 mt-1">Lege eine Disc ein, um einen Ripping-Prozess zu starten.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead className="bg-slate-950/90 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
<tr>
<th className="px-6 py-3.5">Typ</th>
<th className="px-6 py-3.5">Titel &amp; Inhaltsangabe</th>
<th className="px-6 py-3.5">Status &amp; Phase</th>
<th className="px-6 py-3.5">Laufwerk</th>
<th className="px-6 py-3.5">Fortschritt</th>
<th className="px-6 py-3.5">Startzeit</th>
<th className="px-6 py-3.5 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800/80 text-sm">
{filteredJobs.map(job => {
const poster = posterUrl(job.meta)
return (
<tr key={job.id} className="transition-colors hover:bg-slate-900/60">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{poster ? (
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80 shadow-md" />
) : (
<div className="w-10 h-14 rounded-lg bg-slate-900 border border-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
<Film size={18} />
</div>
)}
<div className="min-w-0">
<button
onClick={() => setDetailJobId(job.id)}
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1 text-base"
>
{job.title || 'Unbekannter Titel'}
</button>
{job.meta?.overview && (
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
{job.meta.overview}
</p>
)}
</div>
)}
<div className="min-w-0">
<button
onClick={() => setDetailJobId(job.id)}
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1"
>
{job.title || 'Unbekannter Titel'}
</button>
{job.meta?.overview && (
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
{job.meta.overview}
</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="w-32 space-y-1">
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
<span>{job.progress}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details
</Button>
{job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
</Button>
)}
{job.status === 'failed' && job.can_retry && (
<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 || 'Fehlgeschlagen'))
}}
>
<RotateCcw size={13} /> Neu
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
>
<Trash2 size={14} />
</button>
)}
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="w-32 space-y-1">
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
<span>{job.progress}%</span>
</div>
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
<div
className="h-full bg-amber-500 rounded-full transition-all duration-300"
style={{ width: `${job.progress}%` }}
/>
</div>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
{new Date(job.startTime).toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right">
<div className="flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details
</Button>
{job.status === 'completed' && (
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} /> Download
</Button>
)}
{job.status === 'failed' && job.can_retry && (
<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 || 'Fehlgeschlagen'))
}}
>
<RotateCcw size={13} /> Neu
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
title="Löschen"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* 4. Live-Log Section */}