- {displayJobs.map((job) => {
+
+ {/* Echte Medien-Kacheln aus der API */}
+ {realMovieTiles.map((job) => {
const poster = posterUrl(job.meta)
return (
onDetailClick && onDetailClick(job.id)}
- className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 aspect-[2/3] flex flex-col justify-end p-3"
+ className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
>
{poster ? (
)
})}
+
+ {/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
+ {realMovieTiles.length < 5 && (
+
+ )}
+
+ {/* Dynamic Engine Tile: Drive Status */}
+ {realMovieTiles.length < 4 && (
+
+
+ DRIVE READY
+
+
+
Automatische Erkennung
+
Lege eine Disc ein — Rippy rippt sofort los.
+
+
+ 4K UHD · BLU-RAY · DVD · CD
+
+
+ )}
+
+ {/* Dynamic Engine Tile: Media-Server Sync */}
+ {realMovieTiles.length < 3 && (
+
+
+ MEDIA SERVER
+
+
+
Auto-NFO & Poster
+
Jellyfin, Plex & Kodi kompatibel.
+
+
+ Auto-Scan nach Rip
+
+
+ )}
+
+ {/* Dynamic Engine Tile: Lossless Assurance */}
+ {realMovieTiles.length < 2 && (
+
+
+ MakeMKV
+
+
+
Lossless 1:1
+
Alle Tonspuren & Subtitles erhalten.
+
+
+ x265 HandBrake Kompression
+
+
+ )}
)
diff --git a/docker/ui/src/pages/Dashboard.tsx b/docker/ui/src/pages/Dashboard.tsx
index 6f33bda..7e807e8 100644
--- a/docker/ui/src/pages/Dashboard.tsx
+++ b/docker/ui/src/pages/Dashboard.tsx
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
-import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server } from 'lucide-react'
+import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, List, LayoutGrid } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
@@ -10,6 +10,7 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection'
import { HeroCinemaBanner } from '../components/HeroCinemaBanner'
+import { RadialProgress } from '../components/ui/RadialProgress'
interface JobMeta {
year?: number
@@ -61,6 +62,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState
(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
+ const [tableFilter, setTableFilter] = useState<'all' | 'active' | 'completed' | 'failed'>('all')
const [sparklinePoints, setSparklinePoints] = useState([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
const { toast } = useToast()
@@ -95,7 +97,6 @@ export default function Dashboard() {
setJobs(jobsData)
if (sysData) setSystemInfo(sysData)
- // Live Sparkline Graph Punkte dynamisch simulieren basierend auf echter Aktivität
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
setSparklinePoints(prev => [...prev.slice(1), newPoint])
@@ -113,23 +114,26 @@ export default function Dashboard() {
const queueJobs = jobs.filter(j => j.status === 'pending')
const completedJobs = jobs.filter(j => j.status === 'completed')
- // Berechnung ECHTER Speicherplatz-Daten aus der API (/system/info)
const mainPlatz = systemInfo?.plaetze?.[0]
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
-
- // Echte CPU/Job Auslastung
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
const workerCount = systemInfo?.workers?.length || 1
- // Sparkline SVG Path Generator
const sparklinePath = sparklinePoints.map((val, idx) => {
const x = (idx / (sparklinePoints.length - 1)) * 100
const y = 50 - (val / 100) * 40
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'
+ return true
+ })
+
if (loading) {
return (
@@ -141,7 +145,7 @@ export default function Dashboard() {
return (
- {/* 1. Top Hero Cinema Showcase */}
+ {/* 1. Top Hero Cinema Showcase (5 Kacheln vollständig ausgefüllt) */}
api.post(`/jobs/${id}/cancel`).catch(() => {})}
/>
- {/* 2. Main 2-Column Grid Layout */}
+ {/* 2. Main 2-Column Grid Layout (SC1 Mockup Bereich) */}
{/* LEFT COLUMN (lg:col-span-7) */}
@@ -214,27 +218,29 @@ export default function Dashboard() {
- {/* Ripping Queue Card (ECHTE WARTESCHLANGE) */}
+ {/* Ripping Queue Card */}
Ripping Queue ({queueJobs.length})
-
+
{queueJobs.length === 0 ? (
-
+
Warteschlange ist leer (0 Jobs eingereiht)
) : (
- queueJobs.map((j, idx) => (
-
-
-
{idx + 1}.
-
{j.title || 'Unbekannt'}
-
+
+ {queueJobs.map((j, idx) => (
+
+
+ {idx + 1}.
+ {j.title || 'Unbekannt'}
+
+
+
Wartend
-
Wartend
-
- ))
+ ))}
+
)}
@@ -244,7 +250,7 @@ export default function Dashboard() {
{/* RIGHT COLUMN (lg:col-span-5) */}
- {/* Server Status Card mit ECHTEN API-DATEN & LIVE SPARKLINE GRAPH */}
+ {/* Server Status Card */}
@@ -254,8 +260,6 @@ export default function Dashboard() {
-
- {/* Echte API Daten */}
@@ -294,7 +298,6 @@ export default function Dashboard() {
- {/* Dynamischer Live Sparkline Graph */}
- {/* Recently Ripped Card (ECHTE DE-DATENBANK JOBS) */}
+ {/* Recently Ripped Card */}
Zuletzt gerippt ({completedJobs.length})
@@ -364,8 +367,177 @@ export default function Dashboard() {
- {/* 3. Lower Section: Drive Slot & Live Log */}
+ {/* 3. Laufwerke & Disc-Erkennung (Device Discovery Slot) */}
+
+ {/* 4. VOLLSTÄNDIGE JOB-TABELLE & MEDIEN-BIBLIOTHEK (AUS DEM ALTEN DESIGN + METADATEN/BESCHREIBUNGEN) */}
+
+
+
+ Job-Historie & Medien-Bibliothek
+ ({filteredTableJobs.length} Einträge)
+
+
+
+ {/* Filter Pills */}
+
+ {(['all', 'active', 'completed', 'failed'] as const).map((f) => (
+ 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'
+ }`}
+ >
+ {f === 'all' ? 'Alle' : f === 'active' ? 'Aktiv' : f === 'completed' ? 'Fertig' : 'Fehler'}
+
+ ))}
+
+
+ {jobs.length > 0 && (
+
+
+ CSV Export
+
+ )}
+ {jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
+
setAufraeumenOffen(true)}
+ >
+ Aufräumen
+
+ )}
+
+
+
+ {filteredTableJobs.length === 0 ? (
+
+ Keine Jobs für diesen Filter vorhanden.
+
+ ) : (
+
+
+
+
+ Typ
+ Titel & Beschreibung
+ Status
+ Laufwerk
+ Fortschritt
+ Startzeit
+ Aktionen
+
+
+
+ {filteredTableJobs.map(job => {
+ const poster = posterUrl(job.meta)
+ return (
+
+
+
+
+
+
+ {poster ? (
+
+ ) : (
+
+
+
+ )}
+
+
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'}
+
+ {job.meta?.overview && (
+
+ {job.meta.overview}
+
+ )}
+
+
+
+
+
+
+
+ {job.device}
+
+
+
+
+ {job.progress}%
+
+
+
+
+
+ {new Date(job.startTime).toLocaleString('de-DE')}
+
+
+
+ setDetailJobId(job.id)}>
+ Details
+
+
+ {job.status === 'completed' && (
+ setDetailJobId(job.id)}>
+ Download
+
+ )}
+
+ {job.status === 'failed' && job.can_retry && (
+ {
+ api.post(`/jobs/${job.id}/retry-transcode`)
+ .then(() => toast('success', 'Kompression neu eingereiht'))
+ .catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
+ }}
+ >
+ Neu
+
+ )}
+
+ {(job.status === 'completed' || job.status === 'failed') && (
+ jobLoeschen(job)}
+ className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
+ title="Löschen"
+ >
+
+
+ )}
+
+
+
+ )
+ })}
+
+
+
+ )}
+
+
+ {/* 5. Live-Log Section */}
setDetailJobId(null)} />