style(ui): Visuelle Hero Showcase Banner & Poster Cards Grid auf dem Dashboard
Ampel / ampel (push) Successful in 28s

This commit is contained in:
Hitonabi
2026-07-24 16:22:40 +02:00
parent 3643f74eb1
commit f0a8455fc2
2 changed files with 177 additions and 100 deletions
+117 -43
View File
@@ -1,8 +1,18 @@
import { Disc, Play, Sparkles, Film, Shield, Zap, CheckCircle } from 'lucide-react' import { Disc, Play, Sparkles, Film, Shield, Zap, CheckCircle, Download, FileSpreadsheet } from 'lucide-react'
import { RadialProgress } from './ui/RadialProgress' import { RadialProgress } from './ui/RadialProgress'
import { TypeBadge, StatusBadge } from './ui/Badge' import { TypeBadge, StatusBadge } from './ui/Badge'
import { Button } from './ui/Button' import { Button } from './ui/Button'
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job { interface Job {
id: string id: string
type: 'cd' | 'dvd' | 'bluray' | 'uhd' type: 'cd' | 'dvd' | 'bluray' | 'uhd'
@@ -13,10 +23,18 @@ interface Job {
progress: number progress: number
title?: string title?: string
can_retry?: boolean 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/w500${p}`
} }
interface HeroCinemaBannerProps { interface HeroCinemaBannerProps {
aktiverJob?: Job | null aktiverJob?: Job | null
latestJob?: Job | null
onDetailClick?: (jobId: string) => void onDetailClick?: (jobId: string) => void
onCancelClick?: (jobId: string) => void onCancelClick?: (jobId: string) => void
totalJobsCount: number totalJobsCount: number
@@ -25,68 +43,74 @@ interface HeroCinemaBannerProps {
export function HeroCinemaBanner({ export function HeroCinemaBanner({
aktiverJob, aktiverJob,
latestJob,
onDetailClick, onDetailClick,
onCancelClick, onCancelClick,
totalJobsCount, totalJobsCount,
completedCount, completedCount,
}: HeroCinemaBannerProps) { }: HeroCinemaBannerProps) {
// Fall 1: Ein Job rippt oder komprimiert gerade live
if (aktiverJob) { if (aktiverJob) {
const isTranscoding = aktiverJob.status === 'transcoding' const isTranscoding = aktiverJob.status === 'transcoding'
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion' const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
const poster = posterUrl(aktiverJob.meta)
return ( return (
<div className="relative rounded-3xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950/90 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.25)] text-slate-100 mb-8"> <div className="relative rounded-3xl overflow-hidden border-2 border-amber-500/50 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950 p-6 md:p-8 shadow-[0_0_60px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
{/* Background Ambient Glow Effects */} <div className="absolute -top-24 -right-24 w-96 h-96 bg-amber-500/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -top-24 -right-24 w-96 h-96 bg-amber-500/15 rounded-full blur-3xl pointer-events-none" /> <div className="absolute -bottom-24 -left-24 w-96 h-96 bg-indigo-500/20 rounded-full blur-3xl pointer-events-none" />
<div className="absolute -bottom-24 -left-24 w-96 h-96 bg-indigo-500/15 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8"> <div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
{/* Left: 3D Metallic Disc Graphic */}
<div className="flex items-center gap-6"> <div className="flex items-center gap-6">
{/* 3D Spinning Disc Graphic */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black/80 flex items-center justify-center p-1.5 animate-disc-spin border-2 border-slate-700/60"> {poster ? (
<div className="w-full h-full rounded-full border-2 border-dashed border-white/30 flex items-center justify-center bg-slate-950/60"> <div className="relative">
<Disc size={44} className="text-amber-400 drop-shadow-[0_0_12px_rgba(245,158,11,0.6)]" /> <img src={poster} alt="" className="w-28 h-40 md:w-32 md:h-44 rounded-2xl object-cover shadow-2xl border-2 border-amber-500/40" />
<div className="absolute -bottom-3 -right-3 w-16 h-16 rounded-full disc-texture shadow-xl flex items-center justify-center animate-disc-spin border border-amber-400/60">
<Disc size={24} className="text-amber-400" />
</div>
</div> </div>
</div> ) : (
<div className="absolute -bottom-2 -right-2 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md"> <div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black/80 flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-500/60">
NOW RIPPING <div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
</div> <Disc size={44} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
</div>
</div>
)}
</div> </div>
<div className="space-y-2 text-center sm:text-left"> <div className="space-y-2 text-center sm:text-left">
<div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap"> <div className="flex items-center justify-center sm:justify-start gap-2 flex-wrap">
<span className="px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md shadow-amber-500/30">
NOW RIPPING
</span>
<TypeBadge type={aktiverJob.type} /> <TypeBadge type={aktiverJob.type} />
<StatusBadge status={aktiverJob.status} /> <StatusBadge status={aktiverJob.status} />
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-slate-800/80 text-slate-300 border border-slate-700">
{statusText}
</span>
</div> </div>
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent"> <h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
{aktiverJob.title || 'Aktiver Ripping-Prozess'} {aktiverJob.title || 'Aktiver Ripping-Prozess'}
</h2> </h2>
<p className="text-xs text-slate-400 flex items-center justify-center sm:justify-start gap-3"> <p className="text-xs text-slate-400 flex items-center justify-center sm:justify-start gap-3 font-mono">
<span>Laufwerk: <strong className="font-mono text-amber-300/90">{aktiverJob.device}</strong></span> <span>Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong></span>
<span></span> <span></span>
<span>Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</span> <span>{statusText}</span>
</p> </p>
{/* Quality & Audio Badges Mockup */} <div className="flex items-center justify-center sm:justify-start gap-2 pt-1">
<div className="flex items-center justify-center sm:justify-start gap-1.5 pt-1 text-[10px] font-bold text-slate-400"> <span className="px-2.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[10px] font-bold text-amber-400">4K / HD READY</span>
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">HDR10+</span> <span className="px-2.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[10px] font-bold text-slate-300">LOSSLESS MKV</span>
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">DOLBY ATMOS</span> <span className="px-2.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[10px] font-bold text-slate-300">AUTO-METADATA</span>
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">LOSSLESS MKV</span>
</div> </div>
</div> </div>
</div> </div>
{/* Right: Radial Progress Ring & Controls */}
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end"> <div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" /> <RadialProgress progress={aktiverJob.progress} size={100} strokeWidth={9} label="PROGRESS" />
<div className="flex flex-col gap-2 w-full sm:w-auto"> <div className="flex flex-col gap-2.5 w-full sm:w-auto">
<Button <Button
variant="amber" variant="amber"
size="md" size="md"
@@ -108,11 +132,73 @@ export function HeroCinemaBanner({
) )
} }
// Idle Banner (Kein aktiver Job) // Fall 2: Kein aktiver Job, aber es gibt zuletzt gerippte Medien (z.B. Evangelion 2.22) -> FEATURED SHOWCASE
if (latestJob) {
const poster = posterUrl(latestJob.meta)
return (
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-gradient-to-r from-slate-950 via-slate-900 to-slate-950 p-6 md:p-8 shadow-[0_0_40px_-10px_rgba(245,158,11,0.15)] text-slate-100 mb-8">
<div className="absolute -top-24 -right-24 w-96 h-96 bg-amber-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-6">
{/* Poster or Stylized Media Icon */}
{poster ? (
<img src={poster} alt="" className="w-24 h-36 rounded-2xl object-cover shadow-2xl border border-amber-500/30 flex-shrink-0" />
) : (
<div className="w-24 h-32 rounded-2xl bg-gradient-to-br from-amber-500/20 via-indigo-600/20 to-purple-600/20 border border-amber-500/30 flex items-center justify-center flex-shrink-0 shadow-xl">
<Film size={40} className="text-amber-400" />
</div>
)}
<div className="space-y-2 text-center md:text-left">
<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-[10px] font-bold uppercase tracking-wider bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 flex items-center gap-1">
<CheckCircle size={11} /> ZULETZT GERIPPT
</span>
<TypeBadge type={latestJob.type} />
<StatusBadge status={latestJob.status} />
</div>
<h2 className="text-2xl md:text-3xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
{latestJob.title || 'Unbekanntes Medium'}
</h2>
<p className="text-xs text-slate-400">
Fertiggestellt am {new Date(latestJob.startTime).toLocaleDateString('de-DE')} um {new Date(latestJob.startTime).toLocaleTimeString('de-DE')} · Vorbereitet für Media-Server
</p>
<div className="flex items-center justify-center md:justify-start gap-2 pt-1">
<Button
variant="amber"
size="sm"
onClick={() => onDetailClick && onDetailClick(latestJob.id)}
>
<Download size={14} /> Dateien / Details
</Button>
</div>
</div>
</div>
<div className="flex items-center gap-4 text-xs font-medium text-slate-300 bg-slate-900/90 px-4 py-3 rounded-2xl border border-slate-800 flex-shrink-0">
<div className="flex items-center gap-2">
<Zap size={16} className="text-amber-400" />
<span>Erfolgreiche Rips: <strong className="text-amber-400 font-mono text-sm">{completedCount}</strong></span>
</div>
<div className="h-4 w-px bg-slate-800" />
<div className="flex items-center gap-2">
<Shield size={16} className="text-indigo-400" />
<span>MakeMKV Lossless</span>
</div>
</div>
</div>
</div>
)
}
// Standby Banner
return ( return (
<div className="relative rounded-3xl overflow-hidden border border-slate-800/80 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950/60 p-6 md:p-8 shadow-2xl text-slate-100 mb-8"> <div className="relative rounded-3xl overflow-hidden border border-slate-800/80 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950/60 p-6 md:p-8 shadow-2xl text-slate-100 mb-8">
<div className="absolute -top-24 -right-24 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none" />
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="flex items-center gap-5"> <div className="flex items-center gap-5">
<div className="p-4 rounded-2xl bg-gradient-to-br from-amber-500/20 to-indigo-600/20 border border-amber-500/30 shadow-lg shadow-amber-500/10"> <div className="p-4 rounded-2xl bg-gradient-to-br from-amber-500/20 to-indigo-600/20 border border-amber-500/30 shadow-lg shadow-amber-500/10">
@@ -123,7 +209,7 @@ export function HeroCinemaBanner({
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1"> <span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 flex items-center gap-1">
<CheckCircle size={11} /> Ready for Disc <CheckCircle size={11} /> Ready for Disc
</span> </span>
<span className="text-xs text-slate-400 font-mono">System Status: Standby</span> <span className="text-xs text-slate-400 font-mono">System Standby</span>
</div> </div>
<h2 className="text-2xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent"> <h2 className="text-2xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
Rippy Cinema Studio Rippy Cinema Studio
@@ -133,18 +219,6 @@ export function HeroCinemaBanner({
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4 text-xs font-medium text-slate-300 bg-slate-900/80 px-4 py-3 rounded-2xl border border-slate-800 flex-shrink-0">
<div className="flex items-center gap-2">
<Zap size={16} className="text-amber-400" />
<span>Erledigte Jobs: <strong className="text-amber-400 font-mono">{completedCount}</strong></span>
</div>
<div className="h-4 w-px bg-slate-800" />
<div className="flex items-center gap-2">
<Shield size={16} className="text-indigo-400" />
<span>MakeMKV Lossless 1:1</span>
</div>
</div>
</div> </div>
</div> </div>
) )
+60 -57
View File
@@ -100,6 +100,7 @@ export default function Dashboard() {
}, []) }, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
const latestJob = jobs.find(j => j.status === 'completed') || jobs[0] || null
const stats = { const stats = {
pending: jobs.filter(j => j.status === 'pending').length, pending: jobs.filter(j => j.status === 'pending').length,
@@ -123,9 +124,10 @@ export default function Dashboard() {
subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server" subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server"
/> />
{/* Hero Cinema Banner (3D Spinning Disc, Live Progress & Showcase) */} {/* Hero Cinema Banner (Aktiver Rip ODER zuletzt geripptes Medium Showcase) */}
<HeroCinemaBanner <HeroCinemaBanner
aktiverJob={aktiverJob} aktiverJob={aktiverJob}
latestJob={latestJob}
onDetailClick={id => setDetailJobId(id)} onDetailClick={id => setDetailJobId(id)}
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})} onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
totalJobsCount={jobs.length} totalJobsCount={jobs.length}
@@ -133,19 +135,19 @@ export default function Dashboard() {
/> />
{/* System Telemetrie Leiste */} {/* System Telemetrie Leiste */}
<Card> <Card className="glass-panel">
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm"> <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"> <div className="flex items-center gap-2.5">
<Activity size={17} className="text-amber-500" /> <Activity size={17} className="text-amber-400" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Encoder:</span> <span className="text-slate-400 font-medium">Encoder:</span>
{workers.length === 0 ? ( {workers.length === 0 ? (
<span className="text-slate-400 dark:text-slate-500 italic">kein Worker</span> <span className="text-slate-500 italic">kein Worker</span>
) : ( ) : (
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( 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 ${ <span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu') 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-slate-800 text-slate-300 border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30' : 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
}`}> }`}>
{ENCODER_BADGES[e]?.label || e} {ENCODER_BADGES[e]?.label || e}
</span> </span>
@@ -154,29 +156,29 @@ export default function Dashboard() {
</div> </div>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Clock size={17} className="text-amber-500" /> <Clock size={17} className="text-amber-400" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span> <span className="text-slate-400 font-medium">Aktiv:</span>
{aktiverJob ? ( {aktiverJob ? (
<span className="text-slate-900 dark:text-slate-200 font-medium"> <span className="text-slate-200 font-medium">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span> </span>
) : ( ) : (
<span className="text-slate-400 dark:text-slate-500 italic">kein Job</span> <span className="text-slate-500 italic">kein Job</span>
)} )}
</div> </div>
{plaetze.length > 0 && ( {plaetze.length > 0 && (
<div className="flex items-center gap-2.5 ml-auto"> <div className="flex items-center gap-2.5 ml-auto">
<HardDrive size={17} className="text-amber-500" /> <HardDrive size={17} className="text-amber-400" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span> <span className="text-slate-400 font-medium">Frei:</span>
{plaetze.map(p => ( {plaetze.map(p => (
<span <span
key={p.name} key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`} 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 ${ className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${
p.frei_gb < 60 p.frei_gb < 60
? 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30 font-semibold' ? 'bg-amber-500/20 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' : 'bg-slate-800 text-slate-300 border-slate-700'
}`} }`}
> >
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB {p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
@@ -196,45 +198,45 @@ export default function Dashboard() {
icon={Clock} icon={Clock}
label="Warteschlange" label="Warteschlange"
value={stats.pending.toString()} value={stats.pending.toString()}
badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20" badgeStyle="bg-amber-500/20 text-amber-400 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.2)]"
/> />
<StatCard <StatCard
icon={Activity} icon={Activity}
label="Bearbeitung" label="Bearbeitung"
value={stats.processing.toString()} value={stats.processing.toString()}
badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20" badgeStyle="bg-sky-500/20 text-sky-400 border-sky-500/30 shadow-[0_0_15px_rgba(14,165,233,0.2)]"
/> />
<StatCard <StatCard
icon={CheckCircle} icon={CheckCircle}
label="Fertig" label="Fertig"
value={stats.completed.toString()} value={stats.completed.toString()}
badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20" badgeStyle="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.2)]"
/> />
<StatCard <StatCard
icon={AlertCircle} icon={AlertCircle}
label="Fehler" label="Fehler"
value={stats.failed.toString()} value={stats.failed.toString()}
badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20" badgeStyle="bg-rose-500/20 text-rose-400 border-rose-500/30 shadow-[0_0_15px_rgba(244,63,94,0.2)]"
/> />
</div> </div>
{/* Recent Jobs: Choice between Visual Poster Grid & Classic Table */} {/* Recent Jobs: Choice between Visual Poster Grid & Classic Table */}
<Card className="overflow-hidden"> <Card className="glass-panel overflow-hidden">
<CardHeader> <CardHeader>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<CardTitle>Neueste Jobs</CardTitle> <CardTitle>Neueste Jobs</CardTitle>
<span className="text-xs text-slate-500 dark:text-slate-400">({jobs.length} insgesamt)</span> <span className="text-xs text-slate-400 font-mono">({jobs.length} insgesamt)</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* View Mode Toggle Button */} {/* 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"> <div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
<button <button
onClick={() => setViewMode('grid')} onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${ className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === 'grid' viewMode === 'grid'
? 'bg-amber-500 text-slate-950 shadow-sm font-bold' ? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200' : 'text-slate-400 hover:text-slate-200'
}`} }`}
title="Visuelle Poster-Kacheln" title="Visuelle Poster-Kacheln"
> >
@@ -244,8 +246,8 @@ export default function Dashboard() {
onClick={() => setViewMode('table')} onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${ className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
viewMode === 'table' viewMode === 'table'
? 'bg-amber-500 text-slate-950 shadow-sm font-bold' ? 'bg-amber-500 text-slate-950 shadow-md font-bold'
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200' : 'text-slate-400 hover:text-slate-200'
}`} }`}
title="Kompakte Liste" title="Kompakte Liste"
> >
@@ -258,7 +260,7 @@ export default function Dashboard() {
href="/api/jobs/export" href="/api/jobs/export"
download download
title="Job-Historie als CSV herunterladen" 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" 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} /> <FileSpreadsheet size={13} />
CSV CSV
@@ -280,11 +282,11 @@ export default function Dashboard() {
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<div className="text-center py-16"> <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"> <div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-800/80 border border-slate-700 text-slate-400">
<Disc size={32} /> <Disc size={32} />
</div> </div>
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div> <div className="mb-1 font-semibold 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> <p className="text-sm text-slate-500">Lege eine CD, DVD oder Blu-ray ein, um zu starten</p>
</div> </div>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
/* Visual Poster Cards Grid View */ /* Visual Poster Cards Grid View */
@@ -294,31 +296,32 @@ export default function Dashboard() {
return ( return (
<div <div
key={job.id} 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" className="group relative rounded-2xl overflow-hidden border border-slate-800 bg-slate-900/90 backdrop-blur-xl flex flex-col justify-between transition-all hover:border-amber-500/50 hover:shadow-2xl hover:shadow-amber-500/10"
> >
<div className="flex p-4 gap-4"> <div className="flex p-4 gap-4">
{poster ? ( {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" /> <img src={poster} alt={job.title || ''} className="w-24 h-36 rounded-xl object-cover flex-shrink-0 shadow-md border border-slate-700/60 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"> <div className="w-24 h-36 rounded-xl bg-gradient-to-br from-slate-800 via-slate-900 to-slate-950 border border-slate-800 flex flex-col items-center justify-center flex-shrink-0 text-slate-400 shadow-md p-2 text-center">
<Disc size={32} /> <Disc size={28} className="text-amber-400 mb-1" />
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{job.type}</span>
</div> </div>
)} )}
<div className="flex-1 min-w-0 flex flex-col justify-between"> <div className="flex-1 min-w-0 flex flex-col justify-between">
<div> <div>
<div className="flex items-center gap-1.5 flex-wrap mb-1"> <div className="flex items-center gap-1.5 flex-wrap mb-1.5">
<TypeBadge type={job.type} /> <TypeBadge type={job.type} />
<StatusBadge status={job.status} /> <StatusBadge status={job.status} />
</div> </div>
<button <button
onClick={() => setDetailJobId(job.id)} 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" className="text-base font-bold text-left text-slate-100 hover:text-amber-400 transition-colors line-clamp-2"
> >
{job.title || 'Unbekannter Titel'} {job.title || 'Unbekannter Titel'}
</button> </button>
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 font-mono"> <p className="text-[11px] text-slate-400 mt-1 font-mono">
Laufwerk: {job.device} {job.device}
</p> </p>
</div> </div>
@@ -331,7 +334,7 @@ export default function Dashboard() {
</div> </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"> <div className="px-4 py-2.5 bg-slate-950/80 border-t border-slate-800/80 flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}> <Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
Details Details
</Button> </Button>
@@ -379,27 +382,27 @@ export default function Dashboard() {
/* Table View */ /* Table View */
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800"> <thead className="bg-slate-950/90 border-b border-slate-800">
<tr> <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-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-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-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-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-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-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> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800"> <tbody className="divide-y divide-slate-800/80">
{jobs.slice(0, 10).map(job => ( {jobs.slice(0, 10).map(job => (
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40"> <tr key={job.id} className="transition-colors hover:bg-slate-800/40">
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} /> <TypeBadge type={job.type} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<button <button
onClick={() => setDetailJobId(job.id)} onClick={() => setDetailJobId(job.id)}
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" className="text-sm font-bold text-left hover:underline text-slate-100 hover:text-amber-400 transition-colors"
> >
{job.title || 'Unbekannt'} {job.title || 'Unbekannt'}
</button> </button>
@@ -407,19 +410,19 @@ export default function Dashboard() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} /> <StatusBadge status={job.status} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400"> <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-400">
{job.device} {job.device}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<RadialProgress progress={job.progress} size={42} strokeWidth={4} /> <RadialProgress progress={job.progress} size={42} strokeWidth={4} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400"> <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-400">
{new Date(job.startTime).toLocaleString('de-DE')} {new Date(job.startTime).toLocaleString('de-DE')}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
{job.status === 'completed' && ( {job.status === 'completed' && (
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(job.id)}> <Button variant="secondary" size="sm" onClick={() => setDetailJobId(job.id)}>
<Download size={13} className="text-emerald-500" /> Download <Download size={13} className="text-emerald-400" /> Download
</Button> </Button>
)} )}
{(job.status === 'completed' || job.status === 'failed') && ( {(job.status === 'completed' || job.status === 'failed') && (
@@ -458,14 +461,14 @@ export default function Dashboard() {
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) { function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
return ( return (
<Card> <Card className="glass-panel">
<CardContent className="p-5 flex items-center gap-4"> <CardContent className="p-5 flex items-center gap-4">
<div className={`p-3 rounded-xl border ${badgeStyle}`}> <div className={`p-3 rounded-2xl border ${badgeStyle}`}>
<Icon className="w-6 h-6" /> <Icon className="w-6 h-6" />
</div> </div>
<div> <div>
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p> <p className="text-xs font-medium text-slate-400">{label}</p>
<p className="text-3xl font-extrabold text-slate-900 dark:text-slate-100">{value}</p> <p className="text-3xl font-extrabold text-slate-100">{value}</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>