style(ui): Visuelles High-End Overhaul fuer Cinematic Cinema OS (3D Spin-Disc, Hero Banner, Radial Gauges & Poster Grid)
Ampel / ampel (push) Successful in 27s

This commit is contained in:
Hitonabi
2026-07-24 16:18:58 +02:00
parent b8162753a9
commit 3643f74eb1
6 changed files with 457 additions and 171 deletions
+188 -157
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2, LayoutGrid, List, RotateCcw } from 'lucide-react'
import { api } from '../lib/api'
import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
@@ -10,8 +10,20 @@ import ConfirmDialog from '../components/ConfirmDialog'
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'
import { ENCODER_BADGES } from '../lib/design'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
@@ -22,6 +34,13 @@ interface Job {
progress: number
title?: string
can_retry?: boolean
meta?: JobMeta | null
}
function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
async function fetchJobs(): Promise<Job[]> {
@@ -33,32 +52,6 @@ async function fetchJobs(): Promise<Job[]> {
}
}
function ProgressBar({ progress }: { progress: number }) {
return (
<div className="w-32">
<div className="flex items-center justify-between mb-1">
<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 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
? '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}%` }}
/>
</div>
</div>
)
}
interface WorkerInfo {
name: string
encoders: string[]
}
export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([])
@@ -66,6 +59,7 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const { toast } = useToast()
const jobLoeschen = async (job: Job) => {
@@ -126,70 +120,19 @@ export default function Dashboard() {
<div className="space-y-8">
<PageHeader
title="Dashboard"
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server"
/>
{/* 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>
{/* Hero Cinema Banner (3D Spinning Disc, Live Progress & Showcase) */}
<HeroCinemaBanner
aktiverJob={aktiverJob}
onDetailClick={id => setDetailJobId(id)}
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
totalJobsCount={jobs.length}
completedCount={stats.completed}
/>
<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(() => {})}
>
Abbrechen
</Button>
</div>
</div>
</Card>
)}
{/* System-Telemetrie Leiste */}
{/* 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">
@@ -244,7 +187,7 @@ export default function Dashboard() {
</CardContent>
</Card>
{/* Laufwerke & Erkennung */}
{/* Laufwerke & Disc-Erkennung */}
<DeviceDiscovery />
{/* Stats Cards */}
@@ -275,17 +218,46 @@ export default function Dashboard() {
/>
</div>
{/* Recent Jobs Table */}
{/* Recent Jobs: Choice between Visual Poster Grid & Classic Table */}
<Card className="overflow-hidden">
<CardHeader>
<CardTitle>Neueste Jobs</CardTitle>
<div className="flex items-center gap-3">
<span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span>
<CardTitle>Neueste Jobs</CardTitle>
<span className="text-xs text-slate-500 dark:text-slate-400">({jobs.length} insgesamt)</span>
</div>
<div className="flex items-center gap-3">
{/* View Mode Toggle Button */}
<div className="flex items-center p-1 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === 'grid'
? 'bg-amber-500 text-slate-950 shadow-sm font-bold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
title="Visuelle Poster-Kacheln"
>
<LayoutGrid size={15} />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === 'table'
? 'bg-amber-500 text-slate-950 shadow-sm font-bold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
title="Kompakte Liste"
>
<List size={15} />
</button>
</div>
{jobs.length > 0 && (
<a
href="/api/jobs/export"
download
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
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-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} />
@@ -297,38 +269,129 @@ export default function Dashboard() {
variant="secondary"
size="sm"
onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
title="Erledigte Jobs aufräumen"
>
<Trash2 size={13} />
Erledigte aufräumen
Aufräumen
</Button>
)}
</div>
</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.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 divide-slate-200/80 dark:divide-slate-800">
{jobs.length === 0 ? (
{jobs.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-100 dark:bg-slate-800 text-slate-400">
<Disc size={32} />
</div>
<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">Lege eine CD, DVD oder Blu-ray ein, um zu starten</p>
</div>
) : viewMode === 'grid' ? (
/* Visual Poster Cards Grid View */
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
{jobs.slice(0, 9).map(job => {
const poster = posterUrl(job.meta)
return (
<div
key={job.id}
className="group relative rounded-2xl overflow-hidden border border-slate-200/80 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/80 flex flex-col justify-between transition-all hover:border-amber-500/50 hover:shadow-xl hover:shadow-amber-500/10"
>
<div className="flex p-4 gap-4">
{poster ? (
<img src={poster} alt={job.title || ''} className="w-24 h-36 rounded-xl object-cover flex-shrink-0 shadow-md border border-slate-700/50 group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-24 h-36 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-200 dark:bg-slate-900 text-slate-400 border border-slate-300 dark:border-slate-800">
<Disc size={32} />
</div>
)}
<div className="flex-1 min-w-0 flex flex-col justify-between">
<div>
<div className="flex items-center gap-1.5 flex-wrap mb-1">
<TypeBadge type={job.type} />
<StatusBadge status={job.status} />
</div>
<button
onClick={() => setDetailJobId(job.id)}
className="text-base font-bold text-left text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors line-clamp-2"
>
{job.title || 'Unbekannter Titel'}
</button>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 font-mono">
Laufwerk: {job.device}
</p>
</div>
<div className="pt-2 flex items-center justify-between">
<RadialProgress progress={job.progress} size={48} strokeWidth={5} />
<span className="text-[10px] text-slate-400 font-mono">
{new Date(job.startTime).toLocaleTimeString('de-DE')}
</span>
</div>
</div>
</div>
<div className="px-4 py-2.5 bg-slate-100/70 dark:bg-slate-900/90 border-t border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details
</Button>
<div className="flex items-center gap-2">
{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>
)}
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}>
Abbrechen
</Button>
)}
</div>
</div>
</div>
)
})}
</div>
) : (
/* Table View */
<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>
<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>
<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>
) : (
jobs.slice(0, 10).map(job => (
</thead>
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
{jobs.slice(0, 10).map(job => (
<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} />
@@ -336,7 +399,6 @@ export default function Dashboard() {
<td className="px-6 py-4 whitespace-nowrap">
<button
onClick={() => setDetailJobId(job.id)}
title="Details zu diesem Titel anzeigen"
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'}
@@ -349,63 +411,32 @@ export default function Dashboard() {
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} />
<RadialProgress progress={job.progress} size={42} strokeWidth={4} />
</td>
<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
variant="secondary"
size="sm"
onClick={() => setDetailJobId(job.id)}
title="Fertige Dateien herunterladen (öffnet Job-Details)"
>
<Download size={13} className="text-emerald-500" />
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 || 'Neu anstoßen fehlgeschlagen'))
}}
title="Kompression neu anstoßen"
>
Neu komprimieren
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} className="text-emerald-500" /> Download
</Button>
)}
{(job.status === 'completed' || job.status === 'failed') && (
<button
onClick={() => jobLoeschen(job)}
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
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
title="Job abbrechen"
>
Abbrechen
</Button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
))}
</tbody>
</table>
</div>
)}
</Card>
<LiveLogSection />
@@ -434,7 +465,7 @@ function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label:
</div>
<div>
<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>
<p className="text-3xl font-extrabold text-slate-900 dark:text-slate-100">{value}</p>
</div>
</CardContent>
</Card>