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
Ampel / ampel (push) Successful in 27s
This commit is contained in:
+258
-280
@@ -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 & 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 & 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 & 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 & Inhaltsangabe</th>
|
||||
<th className="px-6 py-3.5">Status & 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 */}
|
||||
|
||||
Reference in New Issue
Block a user