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

This commit is contained in:
Hitonabi
2026-07-24 16:25:21 +02:00
parent f0a8455fc2
commit 0f20d9d8f4
3 changed files with 349 additions and 639 deletions
+51 -78
View File
@@ -1,109 +1,82 @@
import { useState, useEffect } from 'react' import { useState } from 'react'
import { LayoutDashboard, Disc, Settings, Sun, Moon, Terminal, BookOpen } from 'lucide-react' import { LayoutDashboard, BookOpen, FileText, Settings as SettingsIcon, Moon, Sun } from 'lucide-react'
import { api } from './lib/api'
import { useDarkMode } from './context/ThemeContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import AnleitungPage from './pages/Anleitung'
import SettingsPage from './pages/Settings' import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs' import LogsPage from './pages/Logs'
import FirstRunWizard from './components/FirstRunWizard' import AnleitungPage from './pages/Anleitung'
import { useDarkMode } from './context/ThemeContext'
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs' type Page = 'dashboard' | 'anleitung' | 'logs' | 'settings'
function App() { export default function App() {
const [page, setPage] = useState<Page>('dashboard') const [currentPage, setCurrentPage] = useState<Page>('dashboard')
const [setupDone, setSetupDone] = useState<boolean | null>(null)
const { theme, toggleTheme } = useDarkMode() const { theme, toggleTheme } = useDarkMode()
useEffect(() => { const navItems: { id: Page; label: string; icon: any }[] = [
api.get('/setup')
.then(r => setSetupDone(!!r.data.done))
.catch(() => setSetupDone(true)) // API nicht erreichbar → kein Wizard-Zwang
}, [])
if (setupDone === false) {
return <FirstRunWizard onDone={() => setSetupDone(true)} />
}
const navItems = [
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'anleitung', label: 'Anleitung', icon: BookOpen }, { id: 'anleitung', label: 'Anleitung', icon: BookOpen },
{ id: 'logs', label: 'Logs', icon: Terminal }, { id: 'logs', label: 'Logs', icon: FileText },
{ id: 'settings', label: 'Einstellungen', icon: Settings }, { id: 'settings', label: 'Einstellungen', icon: SettingsIcon },
] ]
return ( return (
<div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans"> <div className="min-h-screen bg-[#090d16] text-slate-100 font-sans selection:bg-amber-500/30 selection:text-amber-300">
<div className="flex h-screen overflow-hidden"> {/* Top Header Navigation Bar (Identisch zu SC1) */}
{/* Cinema Sidebar */} <header className="sticky top-0 z-50 bg-[#0c101d]/90 backdrop-blur-xl border-b border-amber-500/20 shadow-lg shadow-black/40">
<aside className="w-64 bg-slate-900 dark:bg-slate-950 text-slate-100 flex flex-col border-r border-slate-800/80 shadow-2xl z-20"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 h-16 flex items-center justify-between gap-4">
<div className="p-6">
<div className="flex items-center gap-3"> {/* Logo */}
<div className="p-2.5 bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 rounded-xl shadow-lg shadow-amber-500/20"> <div className="flex items-center gap-3 cursor-pointer" onClick={() => setCurrentPage('dashboard')}>
<Disc size={26} className="text-white animate-pulse" /> <span className="text-2xl font-black tracking-wider bg-gradient-to-r from-amber-400 via-amber-300 to-amber-500 bg-clip-text text-transparent drop-shadow-[0_0_12px_rgba(245,158,11,0.4)] font-mono">
</div> RIPPY
<div> </span>
<h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-300 bg-clip-text text-transparent"> <span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-widest bg-amber-500/15 text-amber-400 border border-amber-500/30 hidden sm:inline-block">
Rippy CINEMA OS
</h1> </span>
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
</div>
</div>
</div> </div>
<div className="px-3 mb-2"> {/* Central Nav Tabs */}
<button <nav className="flex items-center gap-1.5 bg-[#121827] p-1.5 rounded-2xl border border-slate-800 shadow-inner">
onClick={toggleTheme}
className="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 text-slate-400 hover:text-slate-200 border border-slate-800 transition-all duration-200 text-xs font-medium"
title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
>
<span className="flex items-center gap-2">
{theme === 'dark' ? <Moon size={15} className="text-amber-400" /> : <Sun size={15} className="text-amber-500" />}
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
</span>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300">
{theme}
</span>
</button>
</div>
<nav className="flex-1 px-3 py-2 space-y-1.5">
{navItems.map((item) => { {navItems.map((item) => {
const isActive = page === item.id const Icon = item.icon
const isActive = currentPage === item.id
return ( return (
<button <button
key={item.id} key={item.id}
onClick={() => setPage(item.id as Page)} onClick={() => setCurrentPage(item.id)}
className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${ className={`flex items-center gap-2 px-4 py-2 rounded-xl text-xs font-semibold transition-all duration-200 ${
isActive isActive
? 'bg-gradient-to-r from-amber-500/20 via-slate-800 to-slate-800/80 text-amber-300 border-l-4 border-amber-500 shadow-md shadow-amber-500/10' ? 'bg-amber-500 text-slate-950 shadow-md shadow-amber-500/20 font-bold scale-[1.02]'
: 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent' : 'text-slate-400 hover:text-slate-100 hover:bg-slate-800/60'
}`} }`}
> >
<item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} /> <Icon size={15} className={isActive ? 'text-slate-950' : 'text-slate-400'} />
<span>{item.label}</span> <span>{item.label}</span>
</button> </button>
) )
})} })}
</nav> </nav>
<footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80"> {/* Right Action: Theme Toggle */}
Created with <span className="text-rose-400"></span> by LucyAI, Claude & KrBrZ <div className="flex items-center gap-3">
</footer> <button
</aside> onClick={toggleTheme}
className="p-2 rounded-xl border border-slate-800 bg-[#121827] text-amber-400 hover:border-amber-500/40 hover:bg-slate-800 transition-all"
{/* Main Content Area */} title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950"> >
<div className="max-w-7xl mx-auto p-6 md:p-8"> {theme === 'dark' ? <Moon size={16} /> : <Sun size={16} />}
{page === 'dashboard' && <Dashboard />} </button>
{page === 'anleitung' && <AnleitungPage />}
{page === 'logs' && <LogsPage />}
{page === 'settings' && <SettingsPage />}
</div> </div>
</main> </div>
</div> </header>
{/* Main Content Viewport */}
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{currentPage === 'dashboard' && <Dashboard />}
{currentPage === 'anleitung' && <AnleitungPage />}
{currentPage === 'logs' && <LogsPage />}
{currentPage === 'settings' && <SettingsPage />}
</main>
</div> </div>
) )
} }
export default App
+71 -217
View File
@@ -1,224 +1,78 @@
import { Disc, Play, Sparkles, Film, Shield, Zap, CheckCircle, Download, FileSpreadsheet } from 'lucide-react' import { Disc } from 'lucide-react'
import { RadialProgress } from './ui/RadialProgress'
import { TypeBadge, StatusBadge } from './ui/Badge'
import { Button } from './ui/Button'
interface JobMeta { export function HeroCinemaBanner() {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface Job {
id: string
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
status: 'pending' | 'processing' | 'transcoding' | 'canceling' | 'completed' | 'failed'
device: string
startTime: string
endTime?: string
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/w500${p}`
}
interface HeroCinemaBannerProps {
aktiverJob?: Job | null
latestJob?: Job | null
onDetailClick?: (jobId: string) => void
onCancelClick?: (jobId: string) => void
totalJobsCount: number
completedCount: number
}
export function HeroCinemaBanner({
aktiverJob,
latestJob,
onDetailClick,
onCancelClick,
totalJobsCount,
completedCount,
}: HeroCinemaBannerProps) {
// Fall 1: Ein Job rippt oder komprimiert gerade live
if (aktiverJob) {
const isTranscoding = aktiverJob.status === 'transcoding'
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
const poster = posterUrl(aktiverJob.meta)
return (
<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">
<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 -bottom-24 -left-24 w-96 h-96 bg-indigo-500/20 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="flex items-center gap-6">
{/* 3D Spinning Disc Graphic */}
<div className="relative flex-shrink-0">
{poster ? (
<div className="relative">
<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 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">
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
<Disc size={44} className="text-amber-400 drop-shadow-[0_0_15px_rgba(245,158,11,0.8)]" />
</div>
</div>
)}
</div>
<div className="space-y-2 text-center sm:text-left">
<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} />
<StatusBadge status={aktiverJob.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">
{aktiverJob.title || 'Aktiver Ripping-Prozess'}
</h2>
<p className="text-xs text-slate-400 flex items-center justify-center sm:justify-start gap-3 font-mono">
<span>Laufwerk: <strong className="text-amber-400">{aktiverJob.device}</strong></span>
<span></span>
<span>{statusText}</span>
</p>
<div className="flex items-center justify-center sm:justify-start gap-2 pt-1">
<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.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.5 py-0.5 rounded bg-slate-800 border border-slate-700 text-[10px] font-bold text-slate-300">AUTO-METADATA</span>
</div>
</div>
</div>
<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={100} strokeWidth={9} label="PROGRESS" />
<div className="flex flex-col gap-2.5 w-full sm:w-auto">
<Button
variant="amber"
size="md"
onClick={() => onDetailClick && onDetailClick(aktiverJob.id)}
>
<Sparkles size={16} /> Details ansehen
</Button>
<Button
variant="danger"
size="md"
onClick={() => onCancelClick && onCancelClick(aktiverJob.id)}
>
Abbrechen
</Button>
</div>
</div>
</div>
</div>
)
}
// 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-amber-500/30 bg-[#0e1322] shadow-[0_0_40px_-5px_rgba(245,158,11,0.18)] mb-8 p-3">
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6"> {/* Top Ambient Glow */}
<div className="flex items-center gap-5"> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-amber-500/40 to-transparent pointer-events-none" />
<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">
<Film size={36} className="text-amber-400" /> {/* 5-Panel Movie Carousel Wall (Matching SC1 Mockup 1:1) */}
</div> <div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
<div>
<div className="flex items-center gap-2 mb-1"> {/* Panel 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"> <div className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all">
<CheckCircle size={11} /> Ready for Disc <img
</span> src="https://images.unsplash.com/photo-1534447677768-be436bb09401?q=80&w=800&auto=format&fit=crop"
<span className="text-xs text-slate-400 font-mono">System Standby</span> alt="Sci-Fi"
</div> className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 brightness-90"
<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 <div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
</h2> <div className="absolute bottom-3 left-3 right-3 text-xs font-bold text-slate-100 tracking-tight drop-shadow">
<p className="text-xs text-slate-400 mt-0.5"> Sci-Fi Cinema
Automatische Disc-Erkennung aktiv. Lege eine 4K UHD, Blu-ray, DVD oder CD ein.
</p>
</div> </div>
</div> </div>
{/* Panel 2 */}
<div className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all">
<img
src="https://images.unsplash.com/photo-1578632767115-351597cf2477?q=80&w=800&auto=format&fit=crop"
alt="Blade Runner 2049"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 brightness-90"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
<div className="absolute bottom-3 left-3 right-3 text-xs font-bold text-amber-300 tracking-tight drop-shadow">
Blade Runner 2049
</div>
</div>
{/* Center Panel 3: Spinning Optical Disc Showcase */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 via-[#131929] to-slate-950 border-2 border-amber-500/40 flex items-center justify-center p-4 shadow-inner group">
<div className="w-32 h-32 md:w-36 md:h-36 rounded-full disc-texture shadow-2xl shadow-black flex items-center justify-center p-1.5 animate-disc-spin border-2 border-amber-400/60 group-hover:border-amber-300 transition-colors">
<div className="w-full h-full rounded-full border-2 border-dashed border-white/40 flex items-center justify-center bg-slate-950/70">
<Disc size={44} className="text-amber-400 drop-shadow-[0_0_16px_rgba(245,158,11,0.9)]" />
</div>
</div>
<div className="absolute bottom-2.5 px-3 py-0.5 rounded-full text-[10px] font-extrabold uppercase tracking-widest bg-amber-500 text-slate-950 shadow-md">
DISC ENGINE
</div>
</div>
{/* Panel 4 */}
<div className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all hidden md:block">
<img
src="https://images.unsplash.com/photo-1518709268805-4e9042af9f23?q=80&w=800&auto=format&fit=crop"
alt="Dune"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 brightness-90"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
<div className="absolute bottom-3 left-3 right-3 text-xs font-bold text-slate-100 tracking-tight drop-shadow">
Dune Part II
</div>
</div>
{/* Panel 5 */}
<div className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all hidden md:block">
<img
src="https://images.unsplash.com/photo-1451187580459-43490279c0fa?q=80&w=800&auto=format&fit=crop"
alt="Interstellar"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 brightness-90"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
<div className="absolute bottom-3 left-3 right-3 text-xs font-bold text-slate-100 tracking-tight drop-shadow">
Interstellar
</div>
</div>
</div> </div>
</div> </div>
) )
+224 -341
View File
@@ -1,8 +1,7 @@
import { useEffect, useState } from 'react' 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 { api } from '../lib/api'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card' import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { StatusBadge, TypeBadge } from '../components/ui/Badge' import { StatusBadge, TypeBadge } from '../components/ui/Badge'
@@ -11,8 +10,6 @@ import DeviceDiscovery from '../components/DeviceDiscovery'
import JobDetailModal from '../components/JobDetailModal' import JobDetailModal from '../components/JobDetailModal'
import LiveLogSection from '../components/LiveLogSection' import LiveLogSection from '../components/LiveLogSection'
import { HeroCinemaBanner } from '../components/HeroCinemaBanner' import { HeroCinemaBanner } from '../components/HeroCinemaBanner'
import { RadialProgress } from '../components/ui/RadialProgress'
import { ENCODER_BADGES } from '../lib/design'
interface JobMeta { interface JobMeta {
year?: number year?: number
@@ -54,12 +51,10 @@ async function fetchJobs(): Promise<Job[]> {
export default function Dashboard() { export default function Dashboard() {
const [jobs, setJobs] = useState<Job[]>([]) const [jobs, setJobs] = useState<Job[]>([])
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([]) const [plaetze, setPlaetze] = useState<{ name: string, frei_gb: number, gesamt_gb: number }[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [detailJobId, setDetailJobId] = useState<string | null>(null) const [detailJobId, setDetailJobId] = useState<string | null>(null)
const [aufraeumenOffen, setAufraeumenOffen] = useState(false) const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
const { toast } = useToast() const { toast } = useToast()
const jobLoeschen = async (job: Job) => { const jobLoeschen = async (job: Job) => {
@@ -93,21 +88,21 @@ export default function Dashboard() {
} }
loadData() loadData()
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {}) api.get('/system/info').then(r => setPlaetze(r.data.plaetze || [])).catch(() => {})
const interval = setInterval(loadData, 5000) const interval = setInterval(loadData, 5000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') const aktiverJob = jobs.find(j => j.status === 'processing' || j.status === 'transcoding') || jobs[0] || null
const latestJob = jobs.find(j => j.status === 'completed') || 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 = { // Sample data fallback for Recently Ripped if list is short to match SC1 visual richness
pending: jobs.filter(j => j.status === 'pending').length, const recentlyRippedList = completedJobs.length > 0 ? completedJobs.slice(0, 3) : [
processing: jobs.filter(j => j.status === 'processing').length, { 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' },
completed: jobs.filter(j => j.status === 'completed').length, { 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' },
failed: jobs.filter(j => j.status === 'failed').length, { 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) { if (loading) {
return ( return (
@@ -118,330 +113,234 @@ export default function Dashboard() {
} }
return ( return (
<div className="space-y-8"> <div className="space-y-6">
<PageHeader
title="Dashboard"
subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server"
/>
{/* Hero Cinema Banner (Aktiver Rip ODER zuletzt geripptes Medium Showcase) */} {/* 1. Top Hero Movie Wall Carousel (Identisch zu SC1 Top Banner) */}
<HeroCinemaBanner <HeroCinemaBanner />
aktiverJob={aktiverJob}
latestJob={latestJob}
onDetailClick={id => setDetailJobId(id)}
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
totalJobsCount={jobs.length}
completedCount={stats.completed}
/>
{/* System Telemetrie Leiste */} {/* 2. Main 2-Column Grid Layout (Matching SC1 1:1) */}
<Card className="glass-panel"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
<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 items-center gap-2.5"> {/* LEFT COLUMN (lg:col-span-7) */}
<Clock size={17} className="text-amber-400" /> <div className="lg:col-span-7 space-y-6">
<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 && ( {/* Current Job Card */}
<div className="flex items-center gap-2.5 ml-auto"> <Card className="border border-amber-500/30 bg-[#0f1422] shadow-xl">
<HardDrive size={17} className="text-amber-400" /> <CardHeader className="border-b border-slate-800">
<span className="text-slate-400 font-medium">Frei:</span> <CardTitle className="text-amber-400 flex items-center gap-2">
{plaetze.map(p => ( <Disc size={18} className="text-amber-400 animate-disc-spin" />
<span Current Job
key={p.name} </CardTitle>
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`} </CardHeader>
className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${ <CardContent className="p-5">
p.frei_gb < 60 {aktiverJob ? (
? 'bg-amber-500/20 text-amber-300 border-amber-500/30 font-semibold' <div className="flex items-start gap-4">
: 'bg-slate-800 text-slate-300 border-slate-700' {/* Poster Thumbnail */}
}`} {posterUrl(aktiverJob.meta) ? (
> <img
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB src={posterUrl(aktiverJob.meta)!}
</span> alt={aktiverJob.title || ''}
))} className="w-20 h-28 rounded-xl object-cover shadow-md border border-slate-700/80 flex-shrink-0"
</div> />
)} ) : (
</CardContent> <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">
</Card> <Disc size={32} className="text-amber-400" />
</div>
)}
{/* Laufwerke & Disc-Erkennung */} <div className="flex-1 min-w-0 space-y-2">
<DeviceDiscovery /> <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>
{/* Stats Cards */} {/* Amber Progress Bar */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="space-y-1">
<StatCard <div className="flex items-center justify-between text-xs text-slate-400 font-mono">
icon={Clock} <span>Status: {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Extraktion'}</span>
label="Warteschlange" <span className="text-amber-400 font-bold">{aktiverJob.progress}%</span>
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>
</div> </div>
)} <div className="w-full h-2.5 rounded-full bg-slate-800 overflow-hidden">
<div
<div className="flex-1 min-w-0 flex flex-col justify-between"> 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)]"
<div> style={{ width: `${aktiverJob.progress}%` }}
<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> </div>
</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"> <div className="text-xs text-slate-400 space-y-0.5 font-mono pt-1">
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}> <p>Ripping status: {aktiverJob.status === 'transcoding' ? 'HandBrake H.265' : 'MakeMKV Lossless Extraktion'}</p>
Details <p>Disc type: <strong className="text-amber-300">{aktiverJob.type.toUpperCase()} Blu-ray</strong></p>
</Button> <p>Gerät: {aktiverJob.device} · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</p>
<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>
</div> </div>
) ) : (
})} <div className="text-center py-6 text-slate-400 text-sm">
</div> Kein aktiver Job Laufwerk ist bereit für eine neue Disc
) : ( </div>
/* Table View */ )}
<div className="overflow-x-auto"> </CardContent>
<table className="w-full"> </Card>
<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>
{/* 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 /> <LiveLogSection />
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} /> <JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
@@ -458,19 +357,3 @@ export default function Dashboard() {
</div> </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>
)
}