feat(ui): 5-Kachel-Banner ohne schwarze Loecher gefuellt, Vollstaendige Job-Tabelle mit TMDB-Beschreibungen wiederhergestellt
Ampel / ampel (push) Successful in 28s
Ampel / ampel (push) Successful in 28s
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { Disc, Film, Sparkles, CheckCircle, Shield, Zap } from 'lucide-react'
|
import { Disc, Sparkles, CheckCircle, Shield, Zap, Tv, HardDrive } 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'
|
||||||
@@ -45,14 +45,14 @@ export function HeroCinemaBanner({
|
|||||||
onDetailClick,
|
onDetailClick,
|
||||||
onCancelClick,
|
onCancelClick,
|
||||||
}: HeroCinemaBannerProps) {
|
}: HeroCinemaBannerProps) {
|
||||||
// Fall 1: Aktiver Job (Rippt oder Komprimiert)
|
// Fall 1: Aktiver Job (Rippt oder Komprimiert 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)
|
const poster = posterUrl(aktiverJob.meta)
|
||||||
|
|
||||||
return (
|
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_50px_-10px_rgba(245,158,11,0.3)] 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_50px_-10px_rgba(245,158,11,0.35)] text-slate-100 mb-8">
|
||||||
<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">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -108,70 +108,24 @@ export function HeroCinemaBanner({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall 2: Keine echten gerippten Medien vorhanden -> Standby Hero Banner (Keine Fake-Filme!)
|
// Fall 2: Kein aktiver Job -> 5-Panel Kachel-Galerie VOLLSTÄNDIG ausfüllen (Zero "Löcher"!)
|
||||||
if (completedJobs.length === 0) {
|
// Wir füllen freie Slots mit informativen Cinema-Engine-Showcase-Kacheln auf!
|
||||||
return (
|
const realMovieTiles = completedJobs.slice(0, 4)
|
||||||
<div className="relative rounded-3xl overflow-hidden border border-amber-500/30 bg-gradient-to-r from-slate-950 via-[#0d121f] to-slate-950 p-6 md:p-8 shadow-[0_0_35px_-5px_rgba(245,158,11,0.15)] text-slate-100 mb-8">
|
|
||||||
<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="w-24 h-24 rounded-full disc-texture shadow-2xl flex items-center justify-center p-1 animate-disc-spin border-2 border-amber-500/40 flex-shrink-0">
|
|
||||||
<div className="w-full h-full rounded-full border-2 border-dashed border-white/30 flex items-center justify-center bg-slate-950/70">
|
|
||||||
<Disc size={36} className="text-amber-400" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 mb-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
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-slate-400 font-mono">System Status: Bereit</span>
|
|
||||||
</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">
|
|
||||||
Rippy Cinema Studio
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400 mt-1">
|
|
||||||
Automatische Disc-Erkennung aktiv. Lege eine 4K UHD, Blu-ray, DVD oder CD in dein Laufwerk ein.
|
|
||||||
</p>
|
|
||||||
</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>Gerippte Discs: <strong className="text-amber-400 font-mono text-sm">0</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 1:1 Lossless</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fall 3: Echte gerippte Titel vorhanden -> Zeige ECHTE gerippte Discs in der Galerie (Maximal 5 echte Jobs)
|
|
||||||
const displayJobs = completedJobs.slice(0, 5)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<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-4">
|
<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="flex items-center justify-between px-2 mb-3">
|
{/* Top Subtle Amber Line */}
|
||||||
<h3 className="text-xs font-bold uppercase tracking-wider text-amber-400 flex items-center gap-2">
|
<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" />
|
||||||
<Film size={14} /> ECHTE GERIPPTE MEDIEN ({completedJobs.length})
|
|
||||||
</h3>
|
|
||||||
<span className="text-[11px] text-slate-400 font-mono">Aus deiner Rippy-Datenbank</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 h-44 md:h-52">
|
||||||
{displayJobs.map((job) => {
|
{/* Echte Medien-Kacheln aus der API */}
|
||||||
|
{realMovieTiles.map((job) => {
|
||||||
const poster = posterUrl(job.meta)
|
const poster = posterUrl(job.meta)
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={job.id}
|
key={job.id}
|
||||||
onClick={() => onDetailClick && onDetailClick(job.id)}
|
onClick={() => onDetailClick && onDetailClick(job.id)}
|
||||||
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 aspect-[2/3] flex flex-col justify-end p-3"
|
className="relative rounded-2xl overflow-hidden group cursor-pointer border border-slate-800 hover:border-amber-500/50 transition-all bg-slate-900 flex flex-col justify-end p-3"
|
||||||
>
|
>
|
||||||
{poster ? (
|
{poster ? (
|
||||||
<img
|
<img
|
||||||
@@ -196,6 +150,68 @@ export function HeroCinemaBanner({
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{/* Center / Showcase Feature Tile: 3D Spinning Optical Disc */}
|
||||||
|
{realMovieTiles.length < 5 && (
|
||||||
|
<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-28 h-28 md:w-32 md:h-32 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">
|
||||||
|
<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={40} 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Drive Status */}
|
||||||
|
{realMovieTiles.length < 4 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-indigo-950/80 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-emerald-400 text-xs font-bold">
|
||||||
|
<CheckCircle size={14} /> DRIVE READY
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Automatische Erkennung</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Lege eine Disc ein — Rippy rippt sofort los.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-amber-400 font-bold">
|
||||||
|
4K UHD · BLU-RAY · DVD · CD
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Media-Server Sync */}
|
||||||
|
{realMovieTiles.length < 3 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-amber-400 text-xs font-bold">
|
||||||
|
<Tv size={14} /> MEDIA SERVER
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Auto-NFO & Poster</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Jellyfin, Plex & Kodi kompatibel.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400">
|
||||||
|
Auto-Scan nach Rip
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Dynamic Engine Tile: Lossless Assurance */}
|
||||||
|
{realMovieTiles.length < 2 && (
|
||||||
|
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-slate-900 to-slate-950 border border-slate-800 flex flex-col justify-between p-4 hidden md:flex">
|
||||||
|
<div className="flex items-center gap-2 text-indigo-400 text-xs font-bold">
|
||||||
|
<Shield size={14} /> MakeMKV
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-bold text-slate-100">Lossless 1:1</h4>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-0.5">Alle Tonspuren & Subtitles erhalten.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] font-mono text-slate-400">
|
||||||
|
x265 HandBrake Kompression
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server } from 'lucide-react'
|
import { Cpu, HardDrive, Disc, Download, Trash2, Activity, Film, Server, RotateCcw, FileSpreadsheet, List, LayoutGrid } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
|
||||||
@@ -10,6 +10,7 @@ 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'
|
||||||
|
|
||||||
interface JobMeta {
|
interface JobMeta {
|
||||||
year?: number
|
year?: number
|
||||||
@@ -61,6 +62,7 @@ export default function Dashboard() {
|
|||||||
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 [tableFilter, setTableFilter] = useState<'all' | 'active' | 'completed' | 'failed'>('all')
|
||||||
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
const [sparklinePoints, setSparklinePoints] = useState<number[]>([20, 35, 15, 45, 30, 60, 40, 75, 50, 65])
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
@@ -95,7 +97,6 @@ export default function Dashboard() {
|
|||||||
setJobs(jobsData)
|
setJobs(jobsData)
|
||||||
if (sysData) setSystemInfo(sysData)
|
if (sysData) setSystemInfo(sysData)
|
||||||
|
|
||||||
// Live Sparkline Graph Punkte dynamisch simulieren basierend auf echter Aktivität
|
|
||||||
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
const isBusy = jobsData.some((j: Job) => j.status === 'processing' || j.status === 'transcoding')
|
||||||
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
const newPoint = isBusy ? Math.floor(Math.random() * 40) + 60 : Math.floor(Math.random() * 25) + 10
|
||||||
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
setSparklinePoints(prev => [...prev.slice(1), newPoint])
|
||||||
@@ -113,23 +114,26 @@ export default function Dashboard() {
|
|||||||
const queueJobs = jobs.filter(j => j.status === 'pending')
|
const queueJobs = jobs.filter(j => j.status === 'pending')
|
||||||
const completedJobs = jobs.filter(j => j.status === 'completed')
|
const completedJobs = jobs.filter(j => j.status === 'completed')
|
||||||
|
|
||||||
// Berechnung ECHTER Speicherplatz-Daten aus der API (/system/info)
|
|
||||||
const mainPlatz = systemInfo?.plaetze?.[0]
|
const mainPlatz = systemInfo?.plaetze?.[0]
|
||||||
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
const freiGb = mainPlatz ? mainPlatz.frei_gb : 0
|
||||||
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
const gesamtGb = mainPlatz ? mainPlatz.gesamt_gb : 1
|
||||||
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
const belegtPercent = Math.min(100, Math.max(0, Math.round(((gesamtGb - freiGb) / (gesamtGb || 1)) * 100)))
|
||||||
|
|
||||||
// Echte CPU/Job Auslastung
|
|
||||||
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
const cpuPercent = aktiverJob ? aktiverJob.progress : (jobs.length > 0 ? 15 : 5)
|
||||||
const workerCount = systemInfo?.workers?.length || 1
|
const workerCount = systemInfo?.workers?.length || 1
|
||||||
|
|
||||||
// Sparkline SVG Path Generator
|
|
||||||
const sparklinePath = sparklinePoints.map((val, idx) => {
|
const sparklinePath = sparklinePoints.map((val, idx) => {
|
||||||
const x = (idx / (sparklinePoints.length - 1)) * 100
|
const x = (idx / (sparklinePoints.length - 1)) * 100
|
||||||
const y = 50 - (val / 100) * 40
|
const y = 50 - (val / 100) * 40
|
||||||
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
|
||||||
}).join(' ')
|
}).join(' ')
|
||||||
|
|
||||||
|
const filteredTableJobs = jobs.filter(j => {
|
||||||
|
if (tableFilter === 'active') return j.status === 'processing' || j.status === 'transcoding' || j.status === 'pending'
|
||||||
|
if (tableFilter === 'completed') return j.status === 'completed'
|
||||||
|
if (tableFilter === 'failed') return j.status === 'failed'
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-[60vh]">
|
<div className="flex items-center justify-center min-h-[60vh]">
|
||||||
@@ -141,7 +145,7 @@ export default function Dashboard() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* 1. Top Hero Cinema Showcase */}
|
{/* 1. Top Hero Cinema Showcase (5 Kacheln vollständig ausgefüllt) */}
|
||||||
<HeroCinemaBanner
|
<HeroCinemaBanner
|
||||||
aktiverJob={aktiverJob}
|
aktiverJob={aktiverJob}
|
||||||
completedJobs={completedJobs}
|
completedJobs={completedJobs}
|
||||||
@@ -149,7 +153,7 @@ export default function Dashboard() {
|
|||||||
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
|
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 2. Main 2-Column Grid Layout */}
|
{/* 2. Main 2-Column Grid Layout (SC1 Mockup Bereich) */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
|
|
||||||
{/* LEFT COLUMN (lg:col-span-7) */}
|
{/* LEFT COLUMN (lg:col-span-7) */}
|
||||||
@@ -214,27 +218,29 @@ export default function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Ripping Queue Card (ECHTE WARTESCHLANGE) */}
|
{/* Ripping Queue Card */}
|
||||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||||
<CardHeader className="border-b border-slate-800">
|
<CardHeader className="border-b border-slate-800">
|
||||||
<CardTitle className="text-slate-200">Ripping Queue ({queueJobs.length})</CardTitle>
|
<CardTitle className="text-slate-200">Ripping Queue ({queueJobs.length})</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4 space-y-3">
|
<CardContent className="p-4">
|
||||||
{queueJobs.length === 0 ? (
|
{queueJobs.length === 0 ? (
|
||||||
<div className="text-center py-8 text-slate-500 text-xs font-mono">
|
<div className="py-4 text-center text-xs font-mono text-slate-500">
|
||||||
Warteschlange ist leer (0 Jobs eingereiht)
|
Warteschlange ist leer (0 Jobs eingereiht)
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
queueJobs.map((j, idx) => (
|
<div className="space-y-2">
|
||||||
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
|
{queueJobs.map((j, idx) => (
|
||||||
<div className="flex items-center gap-3">
|
<div key={j.id} className="flex items-center justify-between p-3 rounded-xl bg-slate-900/60 border border-slate-800 text-sm">
|
||||||
<span className="font-mono text-amber-400 font-bold">{idx + 1}.</span>
|
<div className="flex items-center gap-3">
|
||||||
<span className="font-medium text-slate-200">{j.title || 'Unbekannt'}</span>
|
<span className="font-mono text-amber-400 font-bold">{idx + 1}.</span>
|
||||||
<TypeBadge type={j.type} />
|
<span className="font-medium text-slate-200">{j.title || 'Unbekannt'}</span>
|
||||||
|
<TypeBadge type={j.type} />
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-400 font-mono">Wartend</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs text-slate-400 font-mono">Wartend</span>
|
))}
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -244,7 +250,7 @@ export default function Dashboard() {
|
|||||||
{/* RIGHT COLUMN (lg:col-span-5) */}
|
{/* RIGHT COLUMN (lg:col-span-5) */}
|
||||||
<div className="lg:col-span-5 space-y-6">
|
<div className="lg:col-span-5 space-y-6">
|
||||||
|
|
||||||
{/* Server Status Card mit ECHTEN API-DATEN & LIVE SPARKLINE GRAPH */}
|
{/* Server Status Card */}
|
||||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||||
<CardHeader className="border-b border-slate-800">
|
<CardHeader className="border-b border-slate-800">
|
||||||
<CardTitle className="text-slate-200 flex items-center gap-2">
|
<CardTitle className="text-slate-200 flex items-center gap-2">
|
||||||
@@ -254,8 +260,6 @@ export default function Dashboard() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-5">
|
<CardContent className="p-5">
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-6">
|
||||||
|
|
||||||
{/* Echte API Daten */}
|
|
||||||
<div className="space-y-4 flex-1 w-full">
|
<div className="space-y-4 flex-1 w-full">
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
<div className="flex items-center justify-between text-xs font-mono mb-1">
|
||||||
@@ -294,7 +298,6 @@ export default function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dynamischer Live Sparkline Graph */}
|
|
||||||
<div className="w-full sm:w-36 h-24 flex-shrink-0 flex items-end justify-center pt-2">
|
<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 transition-all duration-500">
|
<svg viewBox="0 0 100 50" className="w-full h-full stroke-amber-400 fill-amber-500/10 transition-all duration-500">
|
||||||
<path
|
<path
|
||||||
@@ -308,7 +311,7 @@ export default function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Recently Ripped Card (ECHTE DE-DATENBANK JOBS) */}
|
{/* Recently Ripped Card */}
|
||||||
<Card className="border border-slate-800 bg-[#0f1422]">
|
<Card className="border border-slate-800 bg-[#0f1422]">
|
||||||
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
|
<CardHeader className="border-b border-slate-800 flex items-center justify-between">
|
||||||
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
|
<CardTitle className="text-slate-200">Zuletzt gerippt ({completedJobs.length})</CardTitle>
|
||||||
@@ -364,8 +367,177 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 3. Lower Section: Drive Slot & Live Log */}
|
{/* 3. Laufwerke & Disc-Erkennung (Device Discovery Slot) */}
|
||||||
<DeviceDiscovery />
|
<DeviceDiscovery />
|
||||||
|
|
||||||
|
{/* 4. VOLLSTÄNDIGE JOB-TABELLE & MEDIEN-BIBLIOTHEK (AUS DEM ALTEN DESIGN + METADATEN/BESCHREIBUNGEN) */}
|
||||||
|
<Card className="border border-slate-800 bg-[#0f1422] overflow-hidden">
|
||||||
|
<CardHeader className="border-b border-slate-800 flex flex-wrap items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CardTitle className="text-slate-100">Job-Historie & Medien-Bibliothek</CardTitle>
|
||||||
|
<span className="text-xs font-mono text-slate-400">({filteredTableJobs.length} Einträge)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{/* Filter Pills */}
|
||||||
|
<div className="flex items-center p-1 rounded-xl bg-slate-900 border border-slate-800">
|
||||||
|
{(['all', 'active', 'completed', 'failed'] as const).map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setTableFilter(f)}
|
||||||
|
className={`px-3 py-1 rounded-lg text-xs font-semibold transition-all ${
|
||||||
|
tableFilter === f
|
||||||
|
? 'bg-amber-500 text-slate-950 font-bold shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{f === 'all' ? 'Alle' : f === 'active' ? 'Aktiv' : f === 'completed' ? 'Fertig' : 'Fehler'}
|
||||||
|
</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 Export
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setAufraeumenOffen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} /> Aufräumen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
{filteredTableJobs.length === 0 ? (
|
||||||
|
<div className="text-center py-12 text-slate-500 text-xs font-mono">
|
||||||
|
Keine Jobs für diesen Filter vorhanden.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead className="bg-slate-950/80 border-b border-slate-800 text-[11px] font-bold uppercase tracking-wider text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-3.5">Typ</th>
|
||||||
|
<th className="px-6 py-3.5">Titel & Beschreibung</th>
|
||||||
|
<th className="px-6 py-3.5">Status</th>
|
||||||
|
<th className="px-6 py-3.5">Laufwerk</th>
|
||||||
|
<th className="px-6 py-3.5">Fortschritt</th>
|
||||||
|
<th className="px-6 py-3.5">Startzeit</th>
|
||||||
|
<th className="px-6 py-3.5 text-right">Aktionen</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-800/80 text-sm">
|
||||||
|
{filteredTableJobs.map(job => {
|
||||||
|
const poster = posterUrl(job.meta)
|
||||||
|
return (
|
||||||
|
<tr key={job.id} className="transition-colors hover:bg-slate-900/50">
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<TypeBadge type={job.type} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt="" className="w-10 h-14 rounded-lg object-cover flex-shrink-0 border border-slate-700/80" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-14 rounded-lg bg-slate-800 flex items-center justify-center flex-shrink-0 text-slate-500">
|
||||||
|
<Film size={18} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailJobId(job.id)}
|
||||||
|
className="font-bold text-slate-100 hover:text-amber-400 transition-colors text-left block line-clamp-1"
|
||||||
|
>
|
||||||
|
{job.title || 'Unbekannter Titel'}
|
||||||
|
</button>
|
||||||
|
{job.meta?.overview && (
|
||||||
|
<p className="text-xs text-slate-400 line-clamp-2 mt-0.5 leading-snug">
|
||||||
|
{job.meta.overview}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
|
{job.device}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div className="w-32 space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs font-mono text-slate-400">
|
||||||
|
<span>{job.progress}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 rounded-full bg-slate-800 overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-amber-500 rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${job.progress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-xs font-mono text-slate-400">
|
||||||
|
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 whitespace-nowrap text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 5. Live-Log Section */}
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
|
|
||||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
|
|||||||
Reference in New Issue
Block a user