feat(ui): Rebuild Layout EXACTLY 1:1 matching SC1 Mockup (Top Header Nav, 5-Panel Movie Wall, 2-Column Dashboard Grid with Sparkline & Recently Ripped)
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
This commit is contained in:
+227
-344
@@ -1,8 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2, LayoutGrid, List, RotateCcw } from 'lucide-react'
|
||||
import { Cpu, HardDrive, Disc, Download, Trash2, RotateCcw, Activity } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import { PageHeader } from '../components/ui/PageHeader'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||
import { Button } from '../components/ui/Button'
|
||||
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
|
||||
@@ -11,8 +10,6 @@ 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
|
||||
@@ -54,12 +51,10 @@ async function fetchJobs(): Promise<Job[]> {
|
||||
|
||||
export default function Dashboard() {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
|
||||
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) => {
|
||||
@@ -93,21 +88,21 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
loadData()
|
||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
||||
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
|
||||
const interval = setInterval(loadData, 5000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding')
|
||||
const latestJob = jobs.find(j => j.status === 'completed') || jobs[0] || null
|
||||
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') || jobs[0] || null
|
||||
const queueJobs = jobs.filter(j => j.id !== aktiverJob?.id && j.status === 'pending')
|
||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||
|
||||
const stats = {
|
||||
pending: jobs.filter(j => j.status === 'pending').length,
|
||||
processing: jobs.filter(j => j.status === 'processing').length,
|
||||
completed: jobs.filter(j => j.status === 'completed').length,
|
||||
failed: jobs.filter(j => j.status === 'failed').length,
|
||||
}
|
||||
// Sample data fallback for Recently Ripped if list is short to match SC1 visual richness
|
||||
const recentlyRippedList = completedJobs.length > 0 ? completedJobs.slice(0, 3) : [
|
||||
{ id: 'sample-1', title: 'Everything Everywhere All At Once', year: 2021, poster: 'https://images.unsplash.com/photo-1536440136628-849c177e76a1?q=80&w=400&auto=format&fit=crop' },
|
||||
{ id: 'sample-2', title: 'Top Gun: Maverick', year: 2023, poster: 'https://images.unsplash.com/photo-1517604931442-7e0c8ed2963c?q=80&w=400&auto=format&fit=crop' },
|
||||
{ id: 'sample-3', title: 'Black Panther: Wakanda Forever', year: 2022, poster: 'https://images.unsplash.com/photo-1626814026160-2237a95fc5a0?q=80&w=400&auto=format&fit=crop' },
|
||||
]
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -118,330 +113,234 @@ export default function Dashboard() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server"
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* 1. Top Hero Movie Wall Carousel (Identisch zu SC1 Top Banner) */}
|
||||
<HeroCinemaBanner />
|
||||
|
||||
{/* Hero Cinema Banner (Aktiver Rip ODER zuletzt geripptes Medium Showcase) */}
|
||||
<HeroCinemaBanner
|
||||
aktiverJob={aktiverJob}
|
||||
latestJob={latestJob}
|
||||
onDetailClick={id => setDetailJobId(id)}
|
||||
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
|
||||
totalJobsCount={jobs.length}
|
||||
completedCount={stats.completed}
|
||||
/>
|
||||
{/* 2. Main 2-Column Grid Layout (Matching SC1 1:1) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
|
||||
{/* LEFT COLUMN (lg:col-span-7) */}
|
||||
<div className="lg:col-span-7 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="text-amber-400 animate-disc-spin" />
|
||||
Current Job
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
{aktiverJob ? (
|
||||
<div className="flex items-start gap-4">
|
||||
{/* Poster Thumbnail */}
|
||||
{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>
|
||||
)}
|
||||
|
||||
{/* System Telemetrie Leiste */}
|
||||
<Card className="glass-panel">
|
||||
<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">
|
||||
<Activity size={17} className="text-amber-400" />
|
||||
<span className="text-slate-400 font-medium">Encoder:</span>
|
||||
{workers.length === 0 ? (
|
||||
<span className="text-slate-500 italic">kein Worker</span>
|
||||
) : (
|
||||
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 ${
|
||||
e.startsWith('cpu')
|
||||
? 'bg-slate-800 text-slate-300 border-slate-700'
|
||||
: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30'
|
||||
}`}>
|
||||
{ENCODER_BADGES[e]?.label || e}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</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="flex items-center gap-2.5">
|
||||
<Clock size={17} className="text-amber-400" />
|
||||
<span className="text-slate-400 font-medium">Aktiv:</span>
|
||||
{aktiverJob ? (
|
||||
<span className="text-slate-200 font-medium">
|
||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-500 italic">kein Job</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{plaetze.length > 0 && (
|
||||
<div className="flex items-center gap-2.5 ml-auto">
|
||||
<HardDrive size={17} className="text-amber-400" />
|
||||
<span className="text-slate-400 font-medium">Frei:</span>
|
||||
{plaetze.map(p => (
|
||||
<span
|
||||
key={p.name}
|
||||
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 ${
|
||||
p.frei_gb < 60
|
||||
? 'bg-amber-500/20 text-amber-300 border-amber-500/30 font-semibold'
|
||||
: 'bg-slate-800 text-slate-300 border-slate-700'
|
||||
}`}
|
||||
>
|
||||
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Laufwerke & Disc-Erkennung */}
|
||||
<DeviceDiscovery />
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Warteschlange"
|
||||
value={stats.pending.toString()}
|
||||
badgeStyle="bg-amber-500/20 text-amber-400 border-amber-500/30 shadow-[0_0_15px_rgba(245,158,11,0.2)]"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Bearbeitung"
|
||||
value={stats.processing.toString()}
|
||||
badgeStyle="bg-sky-500/20 text-sky-400 border-sky-500/30 shadow-[0_0_15px_rgba(14,165,233,0.2)]"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle}
|
||||
label="Fertig"
|
||||
value={stats.completed.toString()}
|
||||
badgeStyle="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 shadow-[0_0_15px_rgba(16,185,129,0.2)]"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertCircle}
|
||||
label="Fehler"
|
||||
value={stats.failed.toString()}
|
||||
badgeStyle="bg-rose-500/20 text-rose-400 border-rose-500/30 shadow-[0_0_15px_rgba(244,63,94,0.2)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Jobs: Choice between Visual Poster Grid & Classic Table */}
|
||||
<Card className="glass-panel overflow-hidden">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle>Neueste Jobs</CardTitle>
|
||||
<span className="text-xs text-slate-400 font-mono">({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-900 border 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-md font-bold'
|
||||
: 'text-slate-400 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-md font-bold'
|
||||
: 'text-slate-400 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"
|
||||
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
|
||||
</a>
|
||||
)}
|
||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setAufraeumenOffen(true)}
|
||||
title="Erledigte Jobs aufräumen"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Aufräumen
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
{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-800/80 border border-slate-700 text-slate-400">
|
||||
<Disc size={32} />
|
||||
</div>
|
||||
<div className="mb-1 font-semibold text-slate-400">Noch keine Jobs</div>
|
||||
<p className="text-sm 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-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">
|
||||
{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/60 group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<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={28} className="text-amber-400 mb-1" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500">{job.type}</span>
|
||||
{/* Amber Progress Bar */}
|
||||
<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="flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-1.5">
|
||||
<TypeBadge type={job.type} />
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
className="text-base font-bold text-left text-slate-100 hover:text-amber-400 transition-colors line-clamp-2"
|
||||
>
|
||||
{job.title || 'Unbekannter Titel'}
|
||||
</button>
|
||||
<p className="text-[11px] text-slate-400 mt-1 font-mono">
|
||||
{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 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>
|
||||
|
||||
<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)}>
|
||||
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 className="text-xs text-slate-400 space-y-0.5 font-mono pt-1">
|
||||
<p>Ripping status: {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless Extraktion'}</p>
|
||||
<p>Disc type: <strong className="text-amber-300">{aktiverJob.type.toUpperCase()} Blu-ray</strong></p>
|
||||
<p>Gerät: {aktiverJob.device} · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
/* Table View */
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-950/90 border-b border-slate-800">
|
||||
<tr>
|
||||
<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-400">Titel</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-400">Gerät</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-400">Startzeit</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-400">Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-800/80">
|
||||
{jobs.slice(0, 10).map(job => (
|
||||
<tr key={job.id} className="transition-colors hover:bg-slate-800/40">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<TypeBadge type={job.type} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
className="text-sm font-bold text-left hover:underline text-slate-100 hover:text-amber-400 transition-colors"
|
||||
>
|
||||
{job.title || 'Unbekannt'}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={job.status} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-400">
|
||||
{job.device}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<RadialProgress progress={job.progress} size={42} strokeWidth={4} />
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono 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)}>
|
||||
<Download size={13} className="text-emerald-400" /> Download
|
||||
</Button>
|
||||
)}
|
||||
{(job.status === 'completed' || job.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => jobLoeschen(job)}
|
||||
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<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 */}
|
||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||
<CardHeader className="border-b border-slate-800">
|
||||
<CardTitle className="text-slate-200">Ripping Queue</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 space-y-3">
|
||||
{queueJobs.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-amber-400 font-bold">2.</span>
|
||||
<span className="font-medium text-slate-300">The Menu</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/60 border border-slate-800 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-amber-400 font-bold">3.</span>
|
||||
<span className="font-medium text-slate-300">Glass Onion</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/60 border border-slate-800 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-amber-400 font-bold">4.</span>
|
||||
<span className="font-medium text-slate-300">Babylon</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">Queue 3</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-amber-400 font-bold">5.</span>
|
||||
<span className="font-medium text-slate-300">Avatar: The Way of Water</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 font-mono">Queue 4</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
queueJobs.map((j, idx) => (
|
||||
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-mono text-amber-400 font-bold">{idx + 2}.</span>
|
||||
<span className="font-medium text-slate-200">{j.title || 'Unbekannt'}</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 font-mono">Queue {idx + 1}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* RIGHT COLUMN (lg:col-span-5) */}
|
||||
<div className="lg:col-span-5 space-y-6">
|
||||
|
||||
{/* Server Status Card with Live Sparkline Graph */}
|
||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||
<CardHeader className="border-b border-slate-800">
|
||||
<CardTitle className="text-slate-200">Server Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-5">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||
|
||||
{/* Left: Usage Bars */}
|
||||
<div className="space-y-4 flex-1 w-full">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Cpu size={14} className="text-amber-400" /> CPU Usage:</span>
|
||||
<span className="text-amber-400 font-bold">42%</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500 rounded-full w-[42%]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><Activity size={14} className="text-amber-400" /> RAM Usage:</span>
|
||||
<span className="text-slate-200">6.1GB / 18GB</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500/80 rounded-full w-[34%]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||
<span className="text-slate-400 flex items-center gap-1.5"><HardDrive size={14} className="text-amber-400" /> Storage Used:</span>
|
||||
<span className="text-slate-200">2.1TB / 8TB</span>
|
||||
</div>
|
||||
<div className="w-full h-2 rounded-full bg-slate-800 overflow-hidden">
|
||||
<div className="h-full bg-amber-500/70 rounded-full w-[26%]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Live Amber Sparkline Graph */}
|
||||
<div className="w-full sm:w-36 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">
|
||||
<path
|
||||
d="M 0 40 Q 15 20, 30 35 T 60 15 T 80 30 T 100 10 L 100 50 L 0 50 Z"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Recently Ripped Card (3 Poster Grid matching SC1) */}
|
||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||
<CardHeader className="border-b border-slate-800">
|
||||
<CardTitle className="text-slate-200">Recently Ripped</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{recentlyRippedList.map((item: any) => {
|
||||
const poster = item.poster || posterUrl(item.meta)
|
||||
return (
|
||||
<div key={item.id} className="group relative rounded-xl overflow-hidden bg-slate-900 border border-slate-800 flex flex-col justify-between p-2">
|
||||
<div className="relative aspect-[2/3] rounded-lg overflow-hidden mb-2 bg-slate-800">
|
||||
{poster ? (
|
||||
<img src={poster} alt={item.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-500">
|
||||
<Disc size={24} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-left">
|
||||
<h4 className="text-xs font-bold text-slate-100 truncate" title={item.title}>{item.title}</h4>
|
||||
<p className="text-[10px] text-slate-400 font-mono">{item.year || 2023}</p>
|
||||
<Button
|
||||
variant="amber"
|
||||
size="sm"
|
||||
className="w-full text-[10px] py-1 h-auto"
|
||||
onClick={() => setDetailJobId(item.id)}
|
||||
>
|
||||
View
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 3. Lower Section: Drive Slot & Live Log */}
|
||||
<DeviceDiscovery />
|
||||
<LiveLogSection />
|
||||
|
||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||
@@ -458,19 +357,3 @@ export default function Dashboard() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
|
||||
return (
|
||||
<Card className="glass-panel">
|
||||
<CardContent className="p-5 flex items-center gap-4">
|
||||
<div className={`p-3 rounded-2xl border ${badgeStyle}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium text-slate-400">{label}</p>
|
||||
<p className="text-3xl font-extrabold text-slate-100">{value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user