style(ui): Visuelles High-End Overhaul fuer Cinematic Cinema OS (3D Spin-Disc, Hero Banner, Radial Gauges & Poster Grid)
Ampel / ampel (push) Successful in 27s
Ampel / ampel (push) Successful in 27s
This commit is contained in:
@@ -0,0 +1,151 @@
|
|||||||
|
import { Disc, Play, Sparkles, Film, Shield, Zap, CheckCircle } from 'lucide-react'
|
||||||
|
import { RadialProgress } from './ui/RadialProgress'
|
||||||
|
import { TypeBadge, StatusBadge } from './ui/Badge'
|
||||||
|
import { Button } from './ui/Button'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HeroCinemaBannerProps {
|
||||||
|
aktiverJob?: Job | null
|
||||||
|
onDetailClick?: (jobId: string) => void
|
||||||
|
onCancelClick?: (jobId: string) => void
|
||||||
|
totalJobsCount: number
|
||||||
|
completedCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeroCinemaBanner({
|
||||||
|
aktiverJob,
|
||||||
|
onDetailClick,
|
||||||
|
onCancelClick,
|
||||||
|
totalJobsCount,
|
||||||
|
completedCount,
|
||||||
|
}: HeroCinemaBannerProps) {
|
||||||
|
if (aktiverJob) {
|
||||||
|
const isTranscoding = aktiverJob.status === 'transcoding'
|
||||||
|
const statusText = isTranscoding ? 'HandBrake Kompression' : 'MakeMKV Extraktion'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative rounded-3xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-slate-950 via-slate-900 to-indigo-950/90 p-6 md:p-8 shadow-[0_0_50px_-10px_rgba(245,158,11,0.25)] text-slate-100 mb-8">
|
||||||
|
{/* Background Ambient Glow Effects */}
|
||||||
|
<div className="absolute -top-24 -right-24 w-96 h-96 bg-amber-500/15 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<div className="absolute -bottom-24 -left-24 w-96 h-96 bg-indigo-500/15 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col lg:flex-row items-center justify-between gap-8">
|
||||||
|
{/* Left: 3D Metallic Disc Graphic */}
|
||||||
|
<div className="flex items-center gap-6">
|
||||||
|
<div className="relative flex-shrink-0">
|
||||||
|
<div className="w-28 h-28 md:w-32 md:h-32 rounded-full disc-texture shadow-2xl shadow-black/80 flex items-center justify-center p-1.5 animate-disc-spin border-2 border-slate-700/60">
|
||||||
|
<div className="w-full h-full rounded-full border-2 border-dashed border-white/30 flex items-center justify-center bg-slate-950/60">
|
||||||
|
<Disc size={44} className="text-amber-400 drop-shadow-[0_0_12px_rgba(245,158,11,0.6)]" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="absolute -bottom-2 -right-2 px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-amber-500 text-slate-950 shadow-md">
|
||||||
|
NOW RIPPING
|
||||||
|
</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">
|
||||||
|
<TypeBadge type={aktiverJob.type} />
|
||||||
|
<StatusBadge status={aktiverJob.status} />
|
||||||
|
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-semibold bg-slate-800/80 text-slate-300 border border-slate-700">
|
||||||
|
{statusText}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<span>Laufwerk: <strong className="font-mono text-amber-300/90">{aktiverJob.device}</strong></span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Quality & Audio Badges Mockup */}
|
||||||
|
<div className="flex items-center justify-center sm:justify-start gap-1.5 pt-1 text-[10px] font-bold text-slate-400">
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">HDR10+</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">DOLBY ATMOS</span>
|
||||||
|
<span className="px-2 py-0.5 rounded bg-slate-800/80 border border-slate-700/80">LOSSLESS MKV</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Radial Progress Ring & Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-center gap-6 flex-shrink-0 w-full lg:w-auto justify-end">
|
||||||
|
<RadialProgress progress={aktiverJob.progress} size={96} strokeWidth={8} label="PROGRESS" />
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle Banner (Kein aktiver Job)
|
||||||
|
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="absolute -top-24 -right-24 w-80 h-80 bg-indigo-500/10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
|
||||||
|
<div className="relative z-10 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
|
<div className="p-4 rounded-2xl bg-gradient-to-br from-amber-500/20 to-indigo-600/20 border border-amber-500/30 shadow-lg shadow-amber-500/10">
|
||||||
|
<Film size={36} className="text-amber-400" />
|
||||||
|
</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: Standby</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-2xl font-extrabold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-200 bg-clip-text text-transparent">
|
||||||
|
Rippy Cinema Studio
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
|
Automatische Disc-Erkennung aktiv. Lege eine 4K UHD, Blu-ray, DVD oder CD ein.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 text-xs font-medium text-slate-300 bg-slate-900/80 px-4 py-3 rounded-2xl border border-slate-800 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap size={16} className="text-amber-400" />
|
||||||
|
<span>Erledigte Jobs: <strong className="text-amber-400 font-mono">{completedCount}</strong></span>
|
||||||
|
</div>
|
||||||
|
<div className="h-4 w-px bg-slate-800" />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield size={16} className="text-indigo-400" />
|
||||||
|
<span>MakeMKV Lossless 1:1</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11,12 +11,16 @@ export function Card({ children, className = '', glow = false, onClick }: CardPr
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`rounded-2xl border transition-all duration-300 ${
|
className={`rounded-2xl transition-all duration-300 relative overflow-hidden ${
|
||||||
glow ? 'shadow-[0_0_30px_-5px_rgba(245,158,11,0.15)] border-amber-500/30' : 'shadow-lg shadow-black/5 border-slate-200/80 dark:border-slate-800/80'
|
glow
|
||||||
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${
|
? 'glass-panel-amber'
|
||||||
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : ''
|
: 'bg-white/90 dark:bg-slate-900/80 backdrop-blur-xl border border-slate-200/80 dark:border-slate-800/80 shadow-xl shadow-black/10'
|
||||||
|
} text-slate-900 dark:text-slate-100 ${
|
||||||
|
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-2xl hover:-translate-y-0.5' : ''
|
||||||
} ${className}`}
|
} ${className}`}
|
||||||
>
|
>
|
||||||
|
{/* Top subtle highlight line for 3D glass depth */}
|
||||||
|
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-white/20 dark:via-white/10 to-transparent pointer-events-none" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -32,7 +36,7 @@ export function CardHeader({ children, className = '' }: { children: ReactNode;
|
|||||||
|
|
||||||
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<h2 className={`text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
<h2 className={`text-lg font-bold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
|
||||||
{children}
|
{children}
|
||||||
</h2>
|
</h2>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
interface RadialProgressProps {
|
||||||
|
progress: number
|
||||||
|
size?: number
|
||||||
|
strokeWidth?: number
|
||||||
|
label?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RadialProgress({ progress, size = 80, strokeWidth = 7, label, status }: RadialProgressProps) {
|
||||||
|
const radius = (size - strokeWidth) / 2
|
||||||
|
const circumference = radius * 2 * Math.PI
|
||||||
|
const strokeDashoffset = circumference - (progress / 100) * circumference
|
||||||
|
|
||||||
|
const isComplete = progress === 100
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-flex items-center justify-center">
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
{/* Background track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
className="text-slate-800 dark:text-slate-800/80 fill-none"
|
||||||
|
/>
|
||||||
|
{/* Progress track */}
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke="url(#progressGradient)"
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="fill-none transition-all duration-500 ease-out"
|
||||||
|
/>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="progressGradient" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stopColor={isComplete ? '#10b981' : '#f59e0b'} />
|
||||||
|
<stop offset="50%" stopColor={isComplete ? '#059669' : '#6366f1'} />
|
||||||
|
<stop offset="100%" stopColor={isComplete ? '#34d399' : '#a855f7'} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute flex flex-col items-center justify-center text-center">
|
||||||
|
<span className="text-sm font-bold font-mono text-slate-900 dark:text-slate-100 tracking-tighter">
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
{label && <span className="text-[9px] uppercase tracking-wider text-slate-400">{label}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,18 +10,18 @@ interface ThemeContextType {
|
|||||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined)
|
||||||
|
|
||||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
const [theme, setTheme] = useState<Theme>('light')
|
const [theme, setTheme] = useState<Theme>('dark')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const savedTheme = localStorage.getItem('theme')
|
const savedTheme = localStorage.getItem('theme') as Theme | null
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
|
const prefersLight = window.matchMedia('(prefers-color-scheme: light)').matches
|
||||||
|
|
||||||
if (savedTheme === 'dark' || (!savedTheme && prefersDark)) {
|
if (savedTheme === 'light' || (!savedTheme && prefersLight)) {
|
||||||
setTheme('dark')
|
|
||||||
document.documentElement.classList.add('dark')
|
|
||||||
} else {
|
|
||||||
setTheme('light')
|
setTheme('light')
|
||||||
document.documentElement.classList.remove('dark')
|
document.documentElement.classList.remove('dark')
|
||||||
|
} else {
|
||||||
|
setTheme('dark')
|
||||||
|
document.documentElement.classList.add('dark')
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
|||||||
+46
-2
@@ -4,7 +4,11 @@
|
|||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply m-0 bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-400;
|
@apply m-0 bg-slate-950 text-slate-100 font-sans antialiased selection:bg-amber-500/30 selection:text-amber-300;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 10% 0%, rgba(99, 102, 241, 0.08) 0%, transparent 50%),
|
||||||
|
radial-gradient(ellipse at 90% 100%, rgba(245, 158, 11, 0.06) 0%, transparent 50%);
|
||||||
|
background-attachment: fixed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,5 +39,45 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.animate-disc-spin {
|
.animate-disc-spin {
|
||||||
animation: disc-spin 14s linear infinite;
|
animation: disc-spin 12s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-slow {
|
||||||
|
0%, 100% { opacity: 0.4; }
|
||||||
|
50% { opacity: 0.9; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-pulse-slow {
|
||||||
|
animation: pulse-slow 3s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Glass & Shimmer Effects */
|
||||||
|
.glass-panel {
|
||||||
|
background: rgba(15, 23, 42, 0.75);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
-webkit-backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-panel-amber {
|
||||||
|
background: linear-gradient(135deg, rgba(245, 158, 11, 0.12) 0%, rgba(15, 23, 42, 0.85) 100%);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.25);
|
||||||
|
box-shadow: 0 0 35px -5px rgba(245, 158, 11, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Metallic Disc Texture Gradient */
|
||||||
|
.disc-texture {
|
||||||
|
background: conic-gradient(
|
||||||
|
from 0deg,
|
||||||
|
#1e293b 0deg,
|
||||||
|
#475569 45deg,
|
||||||
|
#1e293b 90deg,
|
||||||
|
#64748b 135deg,
|
||||||
|
#1e293b 180deg,
|
||||||
|
#475569 225deg,
|
||||||
|
#1e293b 270deg,
|
||||||
|
#64748b 315deg,
|
||||||
|
#1e293b 360deg
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+172
-141
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
|
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2, LayoutGrid, List, RotateCcw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useToast } from '../context/ToastContext'
|
import { useToast } from '../context/ToastContext'
|
||||||
import { PageHeader } from '../components/ui/PageHeader'
|
import { PageHeader } from '../components/ui/PageHeader'
|
||||||
@@ -10,8 +10,20 @@ import ConfirmDialog from '../components/ConfirmDialog'
|
|||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
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 { RadialProgress } from '../components/ui/RadialProgress'
|
||||||
import { ENCODER_BADGES } from '../lib/design'
|
import { ENCODER_BADGES } from '../lib/design'
|
||||||
|
|
||||||
|
interface JobMeta {
|
||||||
|
year?: number
|
||||||
|
overview?: string
|
||||||
|
poster_path?: string
|
||||||
|
genres?: string[]
|
||||||
|
runtime?: number
|
||||||
|
type?: string
|
||||||
|
source?: string
|
||||||
|
}
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
id: string
|
id: string
|
||||||
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
type: 'cd' | 'dvd' | 'bluray' | 'uhd'
|
||||||
@@ -22,6 +34,13 @@ interface Job {
|
|||||||
progress: number
|
progress: number
|
||||||
title?: string
|
title?: string
|
||||||
can_retry?: boolean
|
can_retry?: boolean
|
||||||
|
meta?: JobMeta | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function posterUrl(meta?: JobMeta | null): string | null {
|
||||||
|
const p = meta?.poster_path
|
||||||
|
if (!p) return null
|
||||||
|
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs(): Promise<Job[]> {
|
async function fetchJobs(): Promise<Job[]> {
|
||||||
@@ -33,32 +52,6 @@ async function fetchJobs(): Promise<Job[]> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProgressBar({ progress }: { progress: number }) {
|
|
||||||
return (
|
|
||||||
<div className="w-32">
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
|
|
||||||
<span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-200 dark:bg-slate-800 rounded-full h-2 overflow-hidden border border-slate-300/30 dark:border-slate-700/30">
|
|
||||||
<div
|
|
||||||
className={`h-2 rounded-full transition-all duration-500 ease-out ${
|
|
||||||
progress === 100
|
|
||||||
? 'bg-emerald-500 shadow-sm shadow-emerald-500/50'
|
|
||||||
: 'bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-600 shadow-sm shadow-indigo-500/50'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
interface WorkerInfo {
|
|
||||||
name: string
|
|
||||||
encoders: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
@@ -66,6 +59,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 [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
|
|
||||||
const jobLoeschen = async (job: Job) => {
|
const jobLoeschen = async (job: Job) => {
|
||||||
@@ -126,70 +120,19 @@ export default function Dashboard() {
|
|||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Dashboard"
|
title="Dashboard"
|
||||||
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
|
subtitle="Dein Heimkino-Control-Center für Discs, Rips & Medien-Server"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
|
{/* Hero Cinema Banner (3D Spinning Disc, Live Progress & Showcase) */}
|
||||||
{aktiverJob && (
|
<HeroCinemaBanner
|
||||||
<Card glow className="relative overflow-hidden border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 p-6 shadow-2xl">
|
aktiverJob={aktiverJob}
|
||||||
<div className="flex flex-col md:flex-row items-center gap-6">
|
onDetailClick={id => setDetailJobId(id)}
|
||||||
<div className="relative flex-shrink-0">
|
onCancelClick={id => api.post(`/jobs/${id}/cancel`).catch(() => {})}
|
||||||
<div className="w-24 h-24 rounded-full bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 flex items-center justify-center p-1 shadow-xl shadow-amber-500/20 animate-disc-spin">
|
totalJobsCount={jobs.length}
|
||||||
<div className="w-full h-full rounded-full border-4 border-dashed border-white/40 flex items-center justify-center">
|
completedCount={stats.completed}
|
||||||
<Disc size={36} className="text-white" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<span className="absolute -top-1 -right-1 flex h-4 w-4">
|
|
||||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
|
|
||||||
<span className="relative inline-flex rounded-full h-4 w-4 bg-amber-500"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 text-center md:text-left space-y-2">
|
|
||||||
<div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
|
|
||||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
|
||||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
|
|
||||||
</span>
|
|
||||||
<TypeBadge type={aktiverJob.type} />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-bold text-slate-100 truncate">
|
|
||||||
{aktiverJob.title || 'Aktiver Job'}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-slate-400">
|
|
||||||
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="pt-2">
|
|
||||||
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
|
|
||||||
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
|
|
||||||
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
|
|
||||||
style={{ width: `${aktiverJob.progress}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
{/* System Telemetrie Leiste */}
|
||||||
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
|
|
||||||
Details
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* System-Telemetrie Leiste */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
|
<CardContent className="p-4 flex flex-wrap items-center gap-x-8 gap-y-3 text-sm">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
@@ -244,7 +187,7 @@ export default function Dashboard() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Laufwerke & Erkennung */}
|
{/* Laufwerke & Disc-Erkennung */}
|
||||||
<DeviceDiscovery />
|
<DeviceDiscovery />
|
||||||
|
|
||||||
{/* Stats Cards */}
|
{/* Stats Cards */}
|
||||||
@@ -275,17 +218,46 @@ export default function Dashboard() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Recent Jobs Table */}
|
{/* Recent Jobs: Choice between Visual Poster Grid & Classic Table */}
|
||||||
<Card className="overflow-hidden">
|
<Card className="overflow-hidden">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Neueste Jobs</CardTitle>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span>
|
<CardTitle>Neueste Jobs</CardTitle>
|
||||||
|
<span className="text-xs text-slate-500 dark:text-slate-400">({jobs.length} insgesamt)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* View Mode Toggle Button */}
|
||||||
|
<div className="flex items-center p-1 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('grid')}
|
||||||
|
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
viewMode === 'grid'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm font-bold'
|
||||||
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title="Visuelle Poster-Kacheln"
|
||||||
|
>
|
||||||
|
<LayoutGrid size={15} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setViewMode('table')}
|
||||||
|
className={`p-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
viewMode === 'table'
|
||||||
|
? 'bg-amber-500 text-slate-950 shadow-sm font-bold'
|
||||||
|
: 'text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title="Kompakte Liste"
|
||||||
|
>
|
||||||
|
<List size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{jobs.length > 0 && (
|
{jobs.length > 0 && (
|
||||||
<a
|
<a
|
||||||
href="/api/jobs/export"
|
href="/api/jobs/export"
|
||||||
download
|
download
|
||||||
title="Job-Historie als CSV herunterladen (öffnet in Excel)"
|
title="Job-Historie als CSV herunterladen"
|
||||||
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
className="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet size={13} />
|
<FileSpreadsheet size={13} />
|
||||||
@@ -297,15 +269,114 @@ export default function Dashboard() {
|
|||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setAufraeumenOffen(true)}
|
onClick={() => setAufraeumenOffen(true)}
|
||||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
|
title="Erledigte Jobs aufräumen"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} />
|
<Trash2 size={13} />
|
||||||
Erledigte aufräumen
|
Aufräumen
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</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-100 dark:bg-slate-800 text-slate-400">
|
||||||
|
<Disc size={32} />
|
||||||
|
</div>
|
||||||
|
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
|
||||||
|
<p className="text-sm text-slate-400 dark:text-slate-500">Lege eine CD, DVD oder Blu-ray ein, um zu starten</p>
|
||||||
|
</div>
|
||||||
|
) : viewMode === 'grid' ? (
|
||||||
|
/* Visual Poster Cards Grid View */
|
||||||
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
|
||||||
|
{jobs.slice(0, 9).map(job => {
|
||||||
|
const poster = posterUrl(job.meta)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={job.id}
|
||||||
|
className="group relative rounded-2xl overflow-hidden border border-slate-200/80 dark:border-slate-800/80 bg-slate-50/50 dark:bg-slate-950/80 flex flex-col justify-between transition-all hover:border-amber-500/50 hover:shadow-xl hover:shadow-amber-500/10"
|
||||||
|
>
|
||||||
|
<div className="flex p-4 gap-4">
|
||||||
|
{poster ? (
|
||||||
|
<img src={poster} alt={job.title || ''} className="w-24 h-36 rounded-xl object-cover flex-shrink-0 shadow-md border border-slate-700/50 group-hover:scale-105 transition-transform duration-300" />
|
||||||
|
) : (
|
||||||
|
<div className="w-24 h-36 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-200 dark:bg-slate-900 text-slate-400 border border-slate-300 dark:border-slate-800">
|
||||||
|
<Disc size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0 flex flex-col justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-1.5 flex-wrap mb-1">
|
||||||
|
<TypeBadge type={job.type} />
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setDetailJobId(job.id)}
|
||||||
|
className="text-base font-bold text-left text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors line-clamp-2"
|
||||||
|
>
|
||||||
|
{job.title || 'Unbekannter Titel'}
|
||||||
|
</button>
|
||||||
|
<p className="text-[11px] text-slate-500 dark:text-slate-400 mt-1 font-mono">
|
||||||
|
Laufwerk: {job.device}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2 flex items-center justify-between">
|
||||||
|
<RadialProgress progress={job.progress} size={48} strokeWidth={5} />
|
||||||
|
<span className="text-[10px] text-slate-400 font-mono">
|
||||||
|
{new Date(job.startTime).toLocaleTimeString('de-DE')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-2.5 bg-slate-100/70 dark:bg-slate-900/90 border-t border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
Details
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{job.status === 'completed' && (
|
||||||
|
<Button variant="amber" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
|
<Download size={13} /> Download
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{job.status === 'failed' && job.can_retry && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||||
|
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||||
|
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Fehlgeschlagen'))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<RotateCcw size={13} /> Neu
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
|
<button
|
||||||
|
onClick={() => jobLoeschen(job)}
|
||||||
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
title="Löschen"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
|
||||||
|
<Button variant="danger" size="sm" onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}>
|
||||||
|
Abbrechen
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Table View */
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
|
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
|
||||||
@@ -320,15 +391,7 @@ export default function Dashboard() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
<tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
|
||||||
{jobs.length === 0 ? (
|
{jobs.slice(0, 10).map(job => (
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="px-6 py-12 text-center">
|
|
||||||
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
|
|
||||||
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
jobs.slice(0, 10).map(job => (
|
|
||||||
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
<tr key={job.id} className="transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/40">
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
@@ -336,7 +399,6 @@ export default function Dashboard() {
|
|||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<button
|
<button
|
||||||
onClick={() => setDetailJobId(job.id)}
|
onClick={() => setDetailJobId(job.id)}
|
||||||
title="Details zu diesem Titel anzeigen"
|
|
||||||
className="text-sm font-medium text-left hover:underline text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors"
|
className="text-sm font-medium text-left hover:underline text-slate-900 dark:text-slate-100 hover:text-amber-500 dark:hover:text-amber-400 transition-colors"
|
||||||
>
|
>
|
||||||
{job.title || 'Unbekannt'}
|
{job.title || 'Unbekannt'}
|
||||||
@@ -349,63 +411,32 @@ export default function Dashboard() {
|
|||||||
{job.device}
|
{job.device}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<ProgressBar progress={job.progress} />
|
<RadialProgress progress={job.progress} size={42} strokeWidth={4} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
|
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
|
||||||
{new Date(job.startTime).toLocaleString('de-DE')}
|
{new Date(job.startTime).toLocaleString('de-DE')}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
{job.status === 'completed' && (
|
{job.status === 'completed' && (
|
||||||
<Button
|
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(job.id)}>
|
||||||
variant="secondary"
|
<Download size={13} className="text-emerald-500" /> Download
|
||||||
size="sm"
|
|
||||||
onClick={() => setDetailJobId(job.id)}
|
|
||||||
title="Fertige Dateien herunterladen (öffnet Job-Details)"
|
|
||||||
>
|
|
||||||
<Download size={13} className="text-emerald-500" />
|
|
||||||
Download
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
{job.status === 'failed' && job.can_retry && (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
|
||||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
|
||||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
|
|
||||||
}}
|
|
||||||
title="Kompression neu anstoßen"
|
|
||||||
>
|
|
||||||
Neu komprimieren
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{(job.status === 'completed' || job.status === 'failed') && (
|
{(job.status === 'completed' || job.status === 'failed') && (
|
||||||
<button
|
<button
|
||||||
onClick={() => jobLoeschen(job)}
|
onClick={() => jobLoeschen(job)}
|
||||||
title="Job aus der Liste entfernen"
|
|
||||||
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Trash2 size={14} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
|
|
||||||
<Button
|
|
||||||
variant="danger"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
|
|
||||||
title="Job abbrechen"
|
|
||||||
>
|
|
||||||
Abbrechen
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
@@ -434,7 +465,7 @@ function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label:
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
|
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
|
||||||
<p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
|
<p className="text-3xl font-extrabold text-slate-900 dark:text-slate-100">{value}</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
Reference in New Issue
Block a user