3 Commits

Author SHA1 Message Date
Hitonabi bd00d4c728 refactor(ui): Dashboard, DeviceDiscovery & Settings auf Cinematic Cinema OS umgestellt
Ampel / ampel (push) Successful in 27s
2026-07-24 16:04:50 +02:00
Hitonabi 90b708302e refactor(ui): Modals & Unterseiten auf Primitives & dark: umgestellt 2026-07-24 16:04:46 +02:00
Hitonabi 7004d798d6 feat(ui): Primitives & Design Tokens fuer Cinematic Cinema OS 2026-07-24 16:04:41 +02:00
22 changed files with 2118 additions and 2148 deletions
+46 -38
View File
@@ -8,8 +8,6 @@ import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs' import LogsPage from './pages/Logs'
import FirstRunWizard from './components/FirstRunWizard' import FirstRunWizard from './components/FirstRunWizard'
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs' type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
function App() { function App() {
@@ -35,58 +33,68 @@ function App() {
] ]
return ( return (
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300"> <div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans">
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar */} {/* Cinema Sidebar */}
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col"> <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="p-6"> <div className="p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl"> <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">
<Disc size={28} className="text-white" /> <Disc size={26} className="text-white animate-pulse" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold tracking-tight">Rippy</h1> <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">
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p> Rippy
</h1>
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
</div> </div>
</div> </div>
</div> </div>
<button <div className="px-3 mb-2">
onClick={toggleTheme} <button
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200" onClick={toggleTheme}
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'} 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'}
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} >
</button> <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"> <nav className="flex-1 px-3 py-2 space-y-1.5">
{navItems.map((item) => ( {navItems.map((item) => {
<button const isActive = page === item.id
key={item.id} return (
onClick={() => setPage(item.id as Page)} <button
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${ key={item.id}
page === item.id onClick={() => setPage(item.id as Page)}
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30' className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
: 'text-slate-400 hover:bg-slate-800 hover:text-white' 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'
> : 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent'
<item.icon size={20} /> }`}
<span className="font-medium">{item.label}</span> >
</button> <item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} />
))} <span>{item.label}</span>
</button>
)
})}
</nav> </nav>
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen <footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80">
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */} Created with <span className="text-rose-400"></span> by LucyAI, Claude & KrBrZ
<footer className="px-4 py-4 text-center text-xs text-slate-500 border-t border-slate-800">
Created with <span className="text-rose-400"></span> by LucyAI, Claude and KrBrZ
</footer> </footer>
</aside> </aside>
{/* Main Content */} {/* Main Content Area */}
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950"> <main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
<div className="max-w-7xl mx-auto p-6"> <div className="max-w-7xl mx-auto p-6 md:p-8">
{page === 'dashboard' && <Dashboard />} {page === 'dashboard' && <Dashboard />}
{page === 'anleitung' && <AnleitungPage />} {page === 'anleitung' && <AnleitungPage />}
{page === 'logs' && <LogsPage />} {page === 'logs' && <LogsPage />}
+34 -44
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
@@ -22,62 +23,51 @@ export default function ConfirmDialog({
cancelText = 'Abbrechen', cancelText = 'Abbrechen',
type = 'info', type = 'info',
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { theme } = useDarkMode()
const getIcon = () => { const getIcon = () => {
switch (type) { switch (type) {
case 'warning': return <span className="text-4xl"></span> case 'warning':
case 'success': return <span className="text-4xl"></span> return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
default: return <span className="text-4xl">🤔</span> case 'success':
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
default:
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
} }
} }
const getConfirmColor = () => { const getConfirmVariant = () => {
switch (type) { switch (type) {
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30' case 'warning':
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30' return 'amber'
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30' case 'success':
return 'primary'
default:
return 'primary'
} }
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all"> <Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> <div className="flex items-start gap-4">
{/* Header */} {getIcon()}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <div className="space-y-2 flex-1">
<div className="flex items-center gap-3"> <h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{getIcon()} {title}
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> </h2>
{title} <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</h2>
</div>
</div>
{/* Body */}
<div className="px-6 py-6">
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{message} {message}
</p> </p>
</div> </div>
{/* Footer */}
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
{cancelText}
</button>
<button
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
>
{confirmText}
</button>
</div>
</div> </div>
</div>
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<Button variant="secondary" onClick={onClose}>
{cancelText}
</Button>
<Button variant={getConfirmVariant()} onClick={onConfirm}>
{confirmText}
</Button>
</div>
</Modal>
) )
} }
+99 -143
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
import { Button } from './ui/Button'
import { TypeBadge } from './ui/Badge'
import RipTargetModal, { RipOptionen } from './RipTargetModal' import RipTargetModal, { RipOptionen } from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur' import MetadataKorrektur from './MetadataKorrektur'
@@ -35,7 +37,6 @@ interface Device {
disc?: DiscInfo disc?: DiscInfo
} }
// TMDB liefert relative Poster-Pfade, OMDb absolute URLs — beides abdecken
function posterUrl(disc?: DiscInfo): string | null { function posterUrl(disc?: DiscInfo): string | null {
const p = disc?.metadata?.poster_path const p = disc?.metadata?.poster_path
if (!p) return null if (!p) return null
@@ -51,12 +52,9 @@ export default function DeviceDiscovery() {
const [actionBusy, setActionBusy] = useState(false) const [actionBusy, setActionBusy] = useState(false)
const [modalDevice, setModalDevice] = useState<Device | null>(null) const [modalDevice, setModalDevice] = useState<Device | null>(null)
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null) const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const refreshDevices = async (manuell = false) => { const refreshDevices = async (manuell = false) => {
// manuell = Klick auf den Refresh-Knopf: dreht das Icon und quittiert
// per Toast — vorher gab der Knopf KEIN sichtbares Feedback.
if (manuell) setRefreshing(true) if (manuell) setRefreshing(true)
try { try {
const response = await api.get('/devices') const response = await api.get('/devices')
@@ -115,233 +113,194 @@ export default function DeviceDiscovery() {
return () => clearInterval(interval) return () => clearInterval(interval)
}, []) }, [])
const getTypeColor = (type: string) => {
switch (type) {
case 'cd': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'
case 'dvd': return theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'
case 'bluray': return theme === 'dark' ? 'bg-pink-900/30 text-pink-400' : 'bg-pink-100 text-pink-600'
case 'uhd': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400' : 'bg-amber-100 text-amber-700'
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
}
}
const typLabel = (type: string) =>
({ cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD' } as Record<string, string>)[type] || type.toUpperCase()
const QUELLEN_LABEL: Record<string, string> = { const QUELLEN_LABEL: Record<string, string> = {
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt', tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
} }
const getStatusColor = (status: string) => { const getStatusBadge = (status: string) => {
switch (status) { switch (status) {
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' case 'empty':
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Keine Disc</span>
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' case 'ready':
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600' return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/30"><CheckCircle size={13} /> Bereit</span>
} case 'ripping':
} return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-sky-500/15 text-sky-600 dark:text-sky-400 border border-sky-500/30 animate-pulse"><RefreshCw size={13} className="animate-spin" /> Rippt</span>
default:
const getStatusIcon = (status: string) => { return <span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-500/15 text-slate-400 border border-slate-500/30"><AlertCircle size={13} /> Unbekannt</span>
switch (status) {
case 'empty': return <AlertCircle size={16} />
case 'ready': return <CheckCircle size={16} />
case 'ripping': return <RefreshCw size={16} className="animate-spin" />
default: return <AlertCircle size={16} />
} }
} }
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center py-8"> <div className="flex items-center justify-center py-8">
<RefreshCw className="animate-spin text-indigo-600" size={24} /> <RefreshCw className="animate-spin text-amber-500" size={24} />
</div> </div>
) )
} }
return ( return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <CardHeader>
<div> <div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2> <CardTitle>Laufwerke</CardTitle>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p> <p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
</div> </div>
<button <Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
onClick={() => refreshDevices(true)} <RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`} </Button>
title="Geräte neu laden" </CardHeader>
>
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
</button>
</div>
<div className="p-6 transition-colors duration-300"> <CardContent>
{/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */} {/* Disc-Karte: WAS liegt gerade im Laufwerk (Auto-Pre-Scan) */}
{devices.filter(d => d.status === 'ready' && d.disc).map(device => ( {devices.filter(d => d.status === 'ready' && d.disc).map(device => (
<div <div
key={`disc-${device.id}`} key={`disc-${device.id}`}
className={`mb-6 rounded-xl overflow-hidden border-2 flex ${theme === 'dark' ? 'border-indigo-500/40 bg-gradient-to-r from-indigo-950/60 to-slate-900' : 'border-indigo-200 bg-gradient-to-r from-indigo-50 to-white'}`} className="mb-6 rounded-2xl overflow-hidden border border-amber-500/40 bg-gradient-to-r from-amber-500/15 via-slate-900 to-slate-950 flex shadow-xl shadow-amber-500/5 backdrop-blur-md"
> >
{posterUrl(device.disc) && ( {posterUrl(device.disc) && (
<img <img
src={posterUrl(device.disc)!} src={posterUrl(device.disc)!}
alt={device.disc!.title} alt={device.disc!.title}
className="w-28 object-cover flex-shrink-0" className="w-32 object-cover flex-shrink-0 border-r border-slate-800"
/> />
)} )}
<div className="p-5 flex-1 min-w-0"> <div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
<p className={`text-xs font-semibold uppercase tracking-wider mb-1 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}> <div>
Im Laufwerk erkannt <p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
</p> Im Laufwerk erkannt
<h3 className={`text-xl font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> </p>
{device.disc!.title} <h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
{device.disc!.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({device.disc!.year})</span> : null} {device.disc!.title}
</h3> {device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
<div className="flex items-center gap-2 mt-2"> </h3>
<span className={`text-xs font-medium px-2 py-0.5 rounded ${getTypeColor(device.type)}`}> <div className="flex items-center gap-2 mt-2">
{device.disc!.disc_type?.toUpperCase()} <TypeBadge type={device.type} />
</span> <span className="text-xs text-slate-500 dark:text-slate-400">
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'} {' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher </span>
</span> </div>
{device.disc!.metadata?.overview && (
<p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
{device.disc!.metadata.overview}
</p>
)}
{device.disc!.bereits_gerippt && (
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
<AlertCircle size={13} className="flex-shrink-0" />
Diese Disc wurde bereits gerippt
{device.disc!.bereits_gerippt.finished_at
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
: ''} nochmal rippen ist möglich.
</p>
)}
</div> </div>
{device.disc!.metadata?.overview && ( <div className="flex items-center gap-3 mt-4">
<p className={`text-sm mt-2 line-clamp-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'}`}> <Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
{device.disc!.metadata.overview}
</p>
)}
{device.disc!.bereits_gerippt && (
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
<AlertCircle size={13} className="flex-shrink-0" />
Diese Disc wurde bereits gerippt
{device.disc!.bereits_gerippt.finished_at
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
: ''} nochmal rippen ist trotzdem möglich.
</p>
)}
<div className="flex items-center gap-3 mt-3">
<button
onClick={() => setModalDevice(device)}
disabled={actionBusy}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
Rippen starten Rippen starten
</button> </Button>
<button <Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
onClick={() => setKorrekturDevice(device)}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'bg-slate-800 text-amber-300 hover:bg-slate-700' : 'bg-slate-100 text-amber-700 hover:bg-slate-200'}`}
>
Nicht korrekt? Nicht korrekt?
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
))} ))}
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300"> <div className="text-center py-12">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-4 bg-slate-100 dark:bg-slate-800 text-slate-400">
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} /> <HardDrive size={32} />
</div> </div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p> <p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
Stelle sicher, dass ein Laufwerk angeschlossen ist Stelle sicher, dass ein Laufwerk angeschlossen ist
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-3">
{devices.map(device => ( {devices.map(device => (
<div key={device.id} className={`rounded-lg p-5 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}> <div key={device.id} className="rounded-xl p-4 border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}> <div className="p-3 rounded-xl bg-slate-200/60 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
<Disc size={24} /> <Disc size={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
</div> </div>
<div> <div>
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<h3 className={`font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="font-semibold text-slate-900 dark:text-slate-100">
{device.name || 'Unbekanntes Laufwerk'} {device.name || 'Unbekanntes Laufwerk'}
</h3> </h3>
{device.status === 'ripping' && ( <TypeBadge type={device.type} />
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
<RefreshCw size={12} className="animate-spin" />
Rippt
</span>
)}
</div> </div>
<div className="flex flex-wrap gap-2 mt-2"> <div className="flex flex-wrap gap-2 mt-2">
<span className={`text-xs font-mono px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs font-mono px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.path} {device.path}
</span> </span>
{device.serial && ( {device.serial && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.serial} {device.serial}
</span> </span>
)} )}
{device.model && ( {device.model && (
<span className={`text-xs px-2 py-1 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs px-2 py-0.5 rounded bg-slate-200/70 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-300 dark:border-slate-700">
{device.model} {device.model}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center gap-2 mt-3"> <div className="mt-3">
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(device.status)}`}> {getStatusBadge(device.status)}
{getStatusIcon(device.status)}
{device.status === 'empty' ? 'Keine Disc' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Typ: {typLabel(device.type)}
</span>
</div> </div>
</div> </div>
</div> </div>
<button <Button
variant="ghost"
size="sm"
onClick={() => { onClick={() => {
setExpandedId(expandedId === device.id ? null : device.id) setExpandedId(expandedId === device.id ? null : device.id)
setActionFeedback(null) setActionFeedback(null)
}} }}
className={`text-sm font-medium transition-colors ${theme === 'dark' ? 'text-indigo-400 hover:text-indigo-300' : 'text-indigo-600 hover:text-indigo-700'}`}
> >
{expandedId === device.id ? 'Schließen' : 'Details'} {expandedId === device.id ? 'Schließen' : 'Details'}
</button> </Button>
</div> </div>
{expandedId === device.id && ( {expandedId === device.id && (
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <div className="mt-4 pt-4 border-t border-slate-200/80 dark:border-slate-800 space-y-3">
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm"> <div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gerätepfad</span> <span className="text-slate-500 dark:text-slate-400">Gerätepfad</span>
<span className={`font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.path}</span> <span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Modell</span> <span className="text-slate-500 dark:text-slate-400">Modell</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>{device.model || 'unbekannt'}</span> <span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Seriennummer</span> <span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span> <span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span> <span className="text-slate-500 dark:text-slate-400">Disc</span>
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}> <span className="text-slate-700 dark:text-slate-300">
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'} {device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 pt-1"> <div className="flex items-center gap-3 pt-1">
<button <Button
variant="amber"
size="sm"
onClick={() => setModalDevice(device)} onClick={() => setModalDevice(device)}
disabled={actionBusy || device.status !== 'ready'} disabled={actionBusy || device.status !== 'ready'}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
> >
Rippen starten Rippen starten
</button> </Button>
<button <Button
variant="secondary"
size="sm"
onClick={() => ejectDisc(device)} onClick={() => ejectDisc(device)}
disabled={actionBusy || device.status !== 'ready'} disabled={actionBusy || device.status !== 'ready'}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${theme === 'dark' ? 'bg-slate-700 text-slate-200 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
> >
Auswerfen Auswerfen
</button> </Button>
</div> </div>
{actionFeedback && ( {actionFeedback && (
<p className={`text-sm ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}> <p className={`text-sm font-medium ${actionFeedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{actionFeedback} {actionFeedback}
</p> </p>
)} )}
@@ -351,10 +310,8 @@ export default function DeviceDiscovery() {
))} ))}
</div> </div>
)} )}
</div> </CardContent>
{/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener
Pfad — dort eingehängte Shares sind direkt wählbar) */}
<RipTargetModal <RipTargetModal
isOpen={modalDevice !== null} isOpen={modalDevice !== null}
initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')} initialType={modalDevice?.type === 'cd' ? 'music' : (modalDevice?.disc?.metadata?.type === 'tv' ? 'series' : 'movies')}
@@ -368,7 +325,6 @@ export default function DeviceDiscovery() {
}} }}
/> />
{/* „Nicht korrekt?"-Popup: Korrektur ohne Seitenwechsel */}
<MetadataKorrektur <MetadataKorrektur
devicePath={korrekturDevice?.path || ''} devicePath={korrekturDevice?.path || ''}
vorschlag={korrekturDevice?.disc?.title} vorschlag={korrekturDevice?.disc?.title}
@@ -376,6 +332,6 @@ export default function DeviceDiscovery() {
onClose={() => setKorrekturDevice(null)} onClose={() => setKorrekturDevice(null)}
onUebernommen={refreshDevices} onUebernommen={refreshDevices}
/> />
</div> </Card>
) )
} }
+53 -57
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react' import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { Button } from './ui/Button'
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one, import { Input, Select } from './ui/Input'
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -29,7 +27,6 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
api.get('/capabilities') api.get('/capabilities')
@@ -59,14 +56,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
} }
} }
const feldKlasse = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
return ( return (
<div className={`min-h-screen flex items-center justify-center p-6 ${theme === 'dark' ? 'bg-slate-950' : 'bg-slate-100'}`}> <div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
<div className={`w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden ${theme === 'dark' ? 'bg-slate-900 border border-slate-800' : 'bg-white'}`}> <div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<div className="bg-gradient-to-r from-indigo-600 to-purple-700 p-8 text-white"> <div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<Disc size={32} /> <Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1> <h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div> </div>
<p className="opacity-90">Einmalige Einrichtung dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p> <p className="opacity-90">Einmalige Einrichtung dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
@@ -75,36 +70,38 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
{/* Metadaten-APIs */} {/* Metadaten-APIs */}
<section> <section>
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Globe size={18} className="text-indigo-500" /> Metadaten-Erkennung <Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
<div> <Input
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> type="password"
TMDB API-Key <span className="text-indigo-500">(empfohlen)</span> label="TMDB API-Key (empfohlen)"
</label> value={tmdbKey}
<input type="password" value={tmdbKey} onChange={e => setTmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf themoviedb.org" /> onChange={e => setTmdbKey(e.target.value)}
</div> placeholder="Kostenlos auf themoviedb.org"
<div> />
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <Input
OMDb API-Key <span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>(optional, zweite Quelle)</span> type="password"
</label> label="OMDb API-Key (optional, zweite Quelle)"
<input type="password" value={omdbKey} onChange={e => setOmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf omdbapi.com" /> value={omdbKey}
</div> onChange={e => setOmdbKey(e.target.value)}
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> placeholder="Kostenlos auf omdbapi.com"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label. Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
</p> </p>
</div> </div>
</section> </section>
{/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */} {/* Media-Server */}
<section> <section>
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-indigo-500" /> Dein Media-Server <Tv size={18} className="text-amber-500" /> Dein Media-Server
</h2> </h2>
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
zusätzlich movie.nfo + poster.jpg dazu — dein Server erkennt alles ohne Nacharbeit. zusätzlich movie.nfo + poster.jpg dazu.
</p> </p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2"> <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => ( {MEDIA_SERVER_OPTIONEN.map(o => (
@@ -113,76 +110,75 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
type="button" type="button"
onClick={() => setMediaServer(o.wert)} onClick={() => setMediaServer(o.wert)}
title={o.hinweis} title={o.hinweis}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${ className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
mediaServer === o.wert mediaServer === o.wert
? theme === 'dark' ? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark'
? 'bg-slate-800 border-slate-700 text-slate-400 hover:border-slate-600'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`} }`}
> >
{o.label} {o.label}
</button> </button>
))} ))}
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis} {MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
</p> </p>
</section> </section>
{/* Verarbeitung + erkannte Hardware */} {/* Verarbeitung + erkannte Hardware */}
<section> <section>
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
<Cpu size={18} className="text-indigo-500" /> Verarbeitung <Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2> </h2>
<div className={`rounded-lg p-4 mb-3 ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> <div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Erkannte Encoder in deinem System:</p> <p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
{workers.length === 0 ? ( {workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet — startet gerade?</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
) : ( ) : (
workers.map(w => ( workers.map(w => (
<div key={w.name} className="mb-1"> <div key={w.name} className="mb-1">
<span className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}: </span> <span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
{w.encoders.map(e => ( {w.encoders.map(e => (
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded mr-1 ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> <span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
}`}>
{ENCODER_LABELS[e] || e} {ENCODER_LABELS[e] || e}
</span> </span>
))} ))}
</div> </div>
)) ))
)} )}
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Tipp: GPU-Maschinen im Netz können als optionale Transcode-Worker angebunden werden (siehe README).
</p>
</div> </div>
<label className={`flex items-center gap-3 mb-3 text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-indigo-600" /> <input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray) Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
</label> </label>
{transcodeEnabled && ( {transcodeEnabled && (
<select value={preset} onChange={e => setPreset(e.target.value)} className={feldKlasse}> <Select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="H.265 MKV 1080p30">H.265 — klein &amp; modern (Standard)</option> <option value="H.265 MKV 1080p30">H.265 — klein &amp; modern (Standard)</option>
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option> <option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
</select> </Select>
)} )}
</section> </section>
{error && <p className="text-sm text-rose-500">{error}</p>} {error && <p className="text-sm text-rose-500">{error}</p>}
<button <Button
variant="amber"
size="lg"
onClick={abschliessen} onClick={abschliessen}
disabled={saving} disabled={saving}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50" className="w-full"
> >
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>} {saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
</button> </Button>
<p className={`text-xs text-center flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
<CheckCircle size={12} /> Läuft komplett lokal keine Cloud, keine Telemetrie. <CheckCircle size={12} /> Läuft komplett lokal keine Cloud, keine Telemetrie.
</p> </p>
</div> </div>
+140 -160
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react' import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort import { StatusBadge } from './ui/Badge'
// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die
// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler).
interface JobMeta { interface JobMeta {
year?: number year?: number
@@ -32,22 +30,12 @@ interface JobDetail {
meta?: JobMeta | null meta?: JobMeta | null
} }
// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs
function posterUrl(meta?: JobMeta | null): string | null { function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path const p = meta?.poster_path
if (!p) return null if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}` return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
} }
const STATUS_LABEL: Record<string, string> = {
pending: 'Wartend',
processing: 'Rippen',
transcoding: 'Komprimieren',
canceling: 'Wird abgebrochen…',
completed: 'Fertig',
failed: 'Fehler',
}
interface JobDatei { interface JobDatei {
name: string name: string
size_mb: number | null size_mb: number | null
@@ -57,7 +45,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
const [detail, setDetail] = useState<JobDetail | null>(null) const [detail, setDetail] = useState<JobDetail | null>(null)
const [dateien, setDateien] = useState<JobDatei[] | null>(null) const [dateien, setDateien] = useState<JobDatei[] | null>(null)
const [laedt, setLaedt] = useState(false) const [laedt, setLaedt] = useState(false)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
if (!jobId) { if (!jobId) {
@@ -69,7 +56,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
api.get(`/jobs/${jobId}/detail`) api.get(`/jobs/${jobId}/detail`)
.then(r => { .then(r => {
setDetail(r.data) setDetail(r.data)
// Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen
if (r.data?.status === 'completed') { if (r.data?.status === 'completed') {
api.get(`/jobs/${jobId}/files`) api.get(`/jobs/${jobId}/files`)
.then(f => setDateien(f.data.files)) .then(f => setDateien(f.data.files))
@@ -86,154 +72,148 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
const poster = posterUrl(meta) const poster = posterUrl(meta)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}> <Modal
<div isOpen={!!jobId}
className={`w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`} onClose={onClose}
onClick={e => e.stopPropagation()} title={detail?.title || 'Job-Details'}
> maxWidth="2xl"
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> >
<h2 className={`text-lg font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> {laedt ? (
{detail?.title || 'Job-Details'} <div className="flex items-center justify-center py-16">
</h2> <Loader2 className="animate-spin text-amber-500" size={28} />
<button onClick={onClose} className={`p-1.5 rounded flex-shrink-0 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<X size={18} />
</button>
</div> </div>
) : !detail ? (
{laedt ? ( <p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
<div className="flex items-center justify-center py-16"> Job nicht gefunden.
<Loader2 className="animate-spin text-indigo-500" size={28} /> </p>
</div> ) : (
) : !detail ? ( <div className="space-y-6">
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> {/* Film/Serien-Info aus der Disc-Erkennung */}
Job nicht gefunden. <div className="flex gap-5">
</p> {poster ? (
) : ( <img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
<div className="p-6 space-y-5"> ) : (
{/* Film/Serien-Info aus der Disc-Erkennung */} <div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
<div className="flex gap-5"> <Film size={36} />
{poster ? (
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" />
) : (
<div className={`w-32 h-48 rounded-lg flex items-center justify-center flex-shrink-0 ${theme === 'dark' ? 'bg-slate-900 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
<Film size={32} />
</div>
)}
<div className="min-w-0">
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{detail.title || 'Unbekannt'}
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null}
</p>
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{meta?.type === 'tv' ? 'Serie' : 'Film'}
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
</p>
{meta?.genres && meta.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{meta.genres.map(g => (
<span key={g} className={`text-xs px-2 py-0.5 rounded-full ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}>{g}</span>
))}
</div>
)}
{meta?.overview ? (
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{meta.overview}
</p>
) : (
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p>
)}
</div> </div>
</div> )}
<div className="min-w-0 flex-1">
{/* Nüchterne Job-Fakten */} <div className="flex items-center gap-2 flex-wrap">
<div className={`rounded-lg p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span> {detail.title || 'Unbekannt'}
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> {meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''} </h3>
</span> <StatusBadge status={detail.status} />
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span> </div>
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span> <p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span> {meta?.type === 'tv' ? 'Serie' : 'Film'}
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> {meta?.source ? ` · Quelle: ${meta.source}` : ''}
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : ''} {meta?.runtime ? ` · ${meta.runtime} min` : ''}
</span> </p>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span> {meta?.genres && meta.genres.length > 0 && (
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <div className="flex flex-wrap gap-1.5 mt-2">
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'} {meta.genres.map(g => (
</span> <span key={g} className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
{(detail.output_path || detail.target_dir) && ( {g}
<> </span>
<span className={`flex items-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}><FolderOpen size={13} /> Ablage</span>
<span className={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
{detail.output_path || detail.target_dir}
</span>
</>
)}
</div>
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
{detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<div className="flex items-center justify-between mb-2">
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
<Download size={15} /> Dateien herunterladen
</p>
{dateien.length > 1 && (
<button
onClick={() => {
// Gestaffelte Einzel-Downloads statt Server-seitigem
// 40-GB-Zip — der Browser fragt ggf. einmal nach
// Erlaubnis für mehrere Downloads.
dateien.forEach((f, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
a.download = f.name
document.body.appendChild(a)
a.click()
a.remove()
}, i * 400)
})
}}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
>
Alle herunterladen ({dateien.length})
</button>
)}
</div>
<div className="space-y-1">
{dateien.map(f => (
<a
key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${theme === 'dark' ? 'text-indigo-300 hover:bg-slate-800' : 'text-indigo-700 hover:bg-slate-100'}`}
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && (
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</a>
))} ))}
</div> </div>
</div> )}
)} {meta?.overview ? (
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
{meta.overview}
</p>
) : (
<p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p>
)}
</div>
</div>
{detail.error && ( {/* Job-Fakten Grid */}
<div className={`rounded-lg p-4 flex items-start gap-2.5 text-sm ${theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-50 text-rose-700'}`}> <div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" /> <span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
<span className="break-words min-w-0">{detail.error}</span> <span className="text-slate-900 dark:text-slate-200">
</div> {detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
<span className="text-slate-900 dark:text-slate-200">
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
</span>
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
<span className="text-slate-900 dark:text-slate-200">
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
</span>
{(detail.output_path || detail.target_dir) && (
<>
<span className="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
<span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
{detail.output_path || detail.target_dir}
</span>
</>
)} )}
</div> </div>
)}
</div> {/* Dateiliste & Download */}
</div> {detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
<Download size={15} className="text-amber-500" /> Dateien herunterladen
</p>
{dateien.length > 1 && (
<Button
variant="amber"
size="sm"
onClick={() => {
dateien.forEach((f, i) => {
setTimeout(() => {
const a = document.createElement('a')
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
a.download = f.name
document.body.appendChild(a)
a.click()
a.remove()
}, i * 400)
})
}}
>
Alle herunterladen ({dateien.length})
</Button>
)}
</div>
<div className="space-y-1.5">
{dateien.map(f => (
<a
key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download
className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</a>
))}
</div>
</div>
)}
{detail.error && (
<div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
<span className="break-words min-w-0">{detail.error}</span>
</div>
)}
</div>
)}
</Modal>
) )
} }
+43 -48
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Card, CardHeader, CardTitle } from './ui/Card'
interface LiveLogEntry { interface LiveLogEntry {
id: string id: string
@@ -14,7 +14,6 @@ interface LiveLogEntry {
export default function LiveLogSection() { export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([]) const [logs, setLogs] = useState<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(null) const [currentJob, setCurrentJob] = useState<any>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
const fetchJobs = async () => { const fetchJobs = async () => {
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
const getStatusColor = (level: string) => { const getStatusColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return 'text-rose-500' case 'error': return 'text-rose-500 dark:text-rose-400'
case 'warning': return 'text-amber-500' case 'warning': return 'text-amber-500 dark:text-amber-400'
case 'success': return 'text-emerald-500' case 'success': return 'text-emerald-500 dark:text-emerald-400'
default: return 'text-blue-500' default: return 'text-sky-500 dark:text-sky-400'
} }
} }
const getStatusIcon = (level: string) => { const getStatusIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
return ( return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <CardHeader>
<div> <div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2> <CardTitle>Live-Log</CardTitle>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
{currentJob {currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)` ? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
: 'Kein aktiver Job'} : 'Kein aktiver Job'}
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
</div> </div>
{currentJob && ( {currentJob && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${ <div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
currentJob.progress === 100 currentJob.progress === 100
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' : 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
}`}> }`}>
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />} {currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
{currentJob.progress}% {currentJob.progress}%
</div> </div>
</div> </div>
)} )}
</div> </CardHeader>
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${ <div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
}`}>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-8 text-slate-500"> <div className="text-center py-8 text-slate-500">
<Terminal size={32} className="mx-auto mb-2 opacity-50" /> <Terminal size={32} className="mx-auto mb-2 opacity-50" />
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
</div> </div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <div key={log.id} className="flex items-start gap-2 text-slate-300">
<span className="text-slate-500 whitespace-nowrap"> <span className="text-slate-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })} {new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
</span> </span>
<span className={`font-bold ${getStatusColor(log.level)}`}> <span className={`font-bold ${getStatusColor(log.level)}`}>
[{log.source}] [{log.source}]
</span> </span>
<span className="flex-1">{log.message}</span> <span className="flex-1 text-slate-200">{log.message}</span>
<span className={getStatusColor(log.level)}> <span className={getStatusColor(log.level)}>
{getStatusIcon(log.level)} {getStatusIcon(log.level)}
</span> </span>
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
</div> </div>
{/* Quick Stats */} {/* Quick Stats */}
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}> {currentJob && (
{currentJob && ( <div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
<> <div>
<div> <p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p> <p className="text-lg font-bold text-slate-900 dark:text-slate-100">
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> {currentJob.progress}%
{currentJob.progress}% </p>
</p> </div>
</div> <div>
<div> <p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> {currentJob.device || 'Unbekannt'}
{currentJob.device || 'Unbekannt'} </p>
</p> </div>
</div> <div>
<div> <p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> {currentJob.type?.toUpperCase() || 'Unbekannt'}
{currentJob.type?.toUpperCase() || 'Unbekannt'} </p>
</p> </div>
</div> </div>
</> )}
)} </Card>
</div>
</div>
) )
} }
+56 -63
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Search, Loader2, X, Film } from 'lucide-react' import { Search, Loader2, Film } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
// „Nicht korrekt?!"-Popup (Commander-Wunsch 23.07.): Korrektur direkt am import { Input } from './ui/Input'
// Dashboard — suchen, richtigen Treffer anklicken, Rippy merkt sich die Wahl
// für diese Disc (30 Tage, per Disc-Fingerabdruck).
interface SuchKandidat { interface SuchKandidat {
title: string title: string
@@ -30,7 +28,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([]) const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
const [laeuft, setLaeuft] = useState(false) const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -74,72 +71,68 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
} }
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <Modal
<div className={`w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> isOpen={isOpen}
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> onClose={onClose}
<div> title="Richtigen Titel wählen"
<h2 className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Richtigen Titel wählen</h2> maxWidth="2xl"
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> >
Deine Wahl gilt für diese Disc auch beim nächsten Einlegen. <div className="space-y-4">
</p> <p className="text-xs text-slate-500 dark:text-slate-400">
</div> Deine Wahl gilt für diese Disc auch beim nächsten Einlegen.
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}> </p>
<X size={18} />
</button>
</div>
<div className="p-6 overflow-y-auto"> <div className="flex gap-3 items-end">
<div className="flex gap-3"> <div className="flex-1">
<input <Input
value={suchbegriff} value={suchbegriff}
onChange={e => setSuchbegriff(e.target.value)} onChange={e => setSuchbegriff(e.target.value)}
onKeyDown={e => e.key === 'Enter' && suchen()} onKeyDown={e => e.key === 'Enter' && suchen()}
autoFocus autoFocus
placeholder="Titel eingeben…" placeholder="Titel eingeben…"
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-slate-300 text-slate-900'}`}
/> />
<button
onClick={suchen}
disabled={!suchbegriff || laeuft}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen
</button>
</div> </div>
<Button
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>} variant="amber"
onClick={suchen}
{kandidaten.length > 0 && ( disabled={!suchbegriff || laeuft}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4"> >
{kandidaten.map((k, i) => ( {laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
<button Suchen
key={i} </Button>
onClick={() => waehlen(k)}
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}
>
{k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
) : (
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
<Film size={32} />
</div>
)}
<div className="p-2">
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p>
</div>
</button>
))}
</div>
)}
</div> </div>
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
{kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
{kandidaten.map((k, i) => (
<button
key={i}
onClick={() => waehlen(k)}
className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
>
{k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
) : (
<div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
<Film size={32} />
</div>
)}
<div className="p-2.5">
<p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
{k.title}
</p>
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p>
</div>
</button>
))}
</div>
)}
</div> </div>
</div> </Modal>
) )
} }
+180 -234
View File
@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react' import { Folder, FolderOpen, File, ArrowUp, CheckCircle, Film, Tv, Music } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
import { Input } from './ui/Input'
interface TargetConfig { interface TargetConfig {
id: string id: string
@@ -15,7 +17,7 @@ export interface RipOptionen {
series?: string series?: string
season?: number season?: number
mainFeatureOnly?: boolean mainFeatureOnly?: boolean
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle) titles?: number[]
} }
interface TitelInfo { interface TitelInfo {
@@ -27,9 +29,9 @@ interface TitelInfo {
interface RipTargetModalProps { interface RipTargetModalProps {
isOpen: boolean isOpen: boolean
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht) initialType?: TargetConfig['type']
discTitle?: string // Vorschlag für den Serien-Namen discTitle?: string
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan deviceId?: string
onClose: () => void onClose: () => void
onSave: (target: TargetConfig, optionen: RipOptionen) => void onSave: (target: TargetConfig, optionen: RipOptionen) => void
} }
@@ -45,8 +47,6 @@ interface BrowseDir {
path: string path: string
} }
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) { export default function RipTargetModal({ isOpen, initialType, discTitle, deviceId, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies') const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
const [serienName, setSerienName] = useState('') const [serienName, setSerienName] = useState('')
@@ -69,7 +69,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
} }
} }
// Polling, solange der Titel-Scan läuft (Info-Lauf dauert 20120 s)
useEffect(() => { useEffect(() => {
if (scanStatus !== 'running' || !deviceId) return if (scanStatus !== 'running' || !deviceId) return
const interval = setInterval(async () => { const interval = setInterval(async () => {
@@ -78,7 +77,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
if (r.data.status === 'done') { if (r.data.status === 'done') {
const tracks: TitelInfo[] = r.data.tracks || [] const tracks: TitelInfo[] = r.data.tracks || []
setTitelListe(tracks) setTitelListe(tracks)
// Vorauswahl: alles ab 5 Minuten (Trailer/Menü-Schnipsel bleiben weg)
setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr))) setGewaehlt(new Set(tracks.filter(t => t.dauer_s >= 300).map(t => t.nr)))
setScanStatus('done') setScanStatus('done')
} else if (r.data.status === 'error') { } else if (r.data.status === 'error') {
@@ -89,6 +87,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
}, 3000) }, 3000)
return () => clearInterval(interval) return () => clearInterval(interval)
}, [scanStatus, deviceId]) }, [scanStatus, deviceId])
const [targets, setTargets] = useState<TargetConfig[]>([ const [targets, setTargets] = useState<TargetConfig[]>([
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true }, { id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true }, { id: '2', name: 'Serien', path: '/app/media/series', type: 'series', isActive: true },
@@ -99,9 +98,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([]) const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [customPath, setCustomPath] = useState('') const [customPath, setCustomPath] = useState('')
const { theme } = useDarkMode()
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
useEffect(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
setSelectedType(initialType || 'movies') setSelectedType(initialType || 'movies')
@@ -144,11 +141,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
optionen.series = serienName.trim() optionen.series = serienName.trim()
optionen.season = Math.max(1, staffel || 1) optionen.season = Math.max(1, staffel || 1)
} }
// Explizite Titel-Auswahl schlägt alles andere
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) { if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
optionen.titles = [...gewaehlt].sort((a, b) => a - b) optionen.titles = [...gewaehlt].sort((a, b) => a - b)
} else if (scanStatus === 'done' && gewaehlt.size > 0) {
// Alle gewählt = normales „all" — keine Einzelaufrufe nötig
} }
const target = targets.find(t => t.type === selectedType) const target = targets.find(t => t.type === selectedType)
if (customPath) { if (customPath) {
@@ -159,243 +153,195 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
onClose() onClose()
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all"> <Modal
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> isOpen={isOpen}
{/* Header */} onClose={onClose}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> title="Rip-Ziel auswählen"
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2> maxWidth="lg"
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> >
Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar. <div className="space-y-5">
</p> <p className="text-xs text-slate-500 dark:text-slate-400">
</div> Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar.
</p>
{/* Body */} {/* Schnellwahl */}
<div className="p-6 space-y-5"> <div className="grid grid-cols-3 gap-3">
{/* Schnellwahl */} {targets.map((t) => {
<div className="grid grid-cols-3 gap-3"> const isSelected = selectedType === t.type && !customPath
{targets.map((t) => ( return (
<button <button
key={t.type} key={t.type}
onClick={() => { setSelectedType(t.type); setCustomPath('') }} onClick={() => { setSelectedType(t.type); setCustomPath('') }}
className={`flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border transition-all ${ className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
selectedType === t.type && !customPath isSelected
? theme === 'dark' ? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400' : 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-700'
: 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark'
? 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-600'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`} }`}
> >
<span className="text-xl"> {t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
{t.type === 'movies' && '🎥'} {t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
{t.type === 'series' && '📺'} {t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
{t.type === 'music' && '🎵'} <span className="text-sm">{t.name}</span>
</button>
)
})}
</div>
{/* Serien-Flow */}
{selectedType === 'series' && (
<div className="rounded-xl p-4 space-y-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
<div className="grid grid-cols-[1fr_90px] gap-3">
<Input
label="Serienname"
value={serienName}
onChange={e => setSerienName(e.target.value)}
placeholder="z. B. Neon Genesis Evangelion"
/>
<Input
label="Staffel"
type="number"
min={1}
value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
/>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
</p>
</div>
)}
{/* Hauptfilm-Wahl */}
{selectedType === 'movies' && scanStatus !== 'done' && (
<label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-amber-500 rounded"
/>
Nur Hauptfilm rippen (längster Titel Extras/Trailer bleiben weg)
</label>
)}
{/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && (
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Disc scannen und einzelne Titel an-/abwählen.
</p>
)}
</div>
{scanStatus === 'running' ? (
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
Disc wird gelesen (20120 s)
</span> </span>
<span className="text-sm font-medium">{t.name}</span> ) : (
<Button variant="secondary" size="sm" onClick={scanStarten}>
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</Button>
)}
</div>
{scanStatus === 'error' && (
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
)}
{scanStatus === 'done' && titelListe.length > 0 && (
<>
<div className="max-h-44 overflow-y-auto">
{titelListe.map(t => (
<label
key={t.nr}
className="flex items-center gap-3 px-4 py-2 text-sm cursor-pointer border-b border-slate-200/50 dark:border-slate-800/50 hover:bg-slate-100 dark:hover:bg-slate-800/60"
>
<input
type="checkbox"
checked={gewaehlt.has(t.nr)}
onChange={e => {
const neu = new Set(gewaehlt)
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
setGewaehlt(neu)
}}
className="w-4 h-4 accent-amber-500 rounded"
/>
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && (
<span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
)}
<span className="ml-auto text-xs font-mono text-slate-400">
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span>
</label>
))}
</div>
<p className="text-xs px-4 py-2 bg-slate-100 dark:bg-slate-900 text-slate-500 dark:text-slate-400">
{gewaehlt.size} von {titelListe.length} Titeln gewählt
</p>
</>
)}
</div>
)}
{/* Ordner-Browser */}
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className="p-1 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(browsePath)}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</Button>
</div>
<div className="max-h-36 overflow-y-auto p-1">
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className="w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left rounded-lg transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={14} className="text-slate-400" />
{d.name}
</button> </button>
))} ))}
</div> </div>
</div>
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden {/* Gewähltes Ziel Display */}
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */} <div className="flex items-center gap-2 text-sm px-3.5 py-2.5 rounded-xl bg-slate-100 dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
{selectedType === 'series' && ( {customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
<div className="grid grid-cols-[1fr_90px] gap-3"> {customPath || targets.find(t => t.type === selectedType)?.path}
<div> </span>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
<input
value={serienName}
onChange={e => setSerienName(e.target.value)}
placeholder="z. B. Neon Genesis Evangelion"
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
<input
type="number"
min={1}
value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
</div>
</div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch
zu S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01" benannt (nur bei eindeutiger Zuordnung).
</p>
</div>
)}
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
eine explizite Titel-Auswahl unten schlägt diese Option */}
{selectedType === 'movies' && scanStatus !== 'done' && (
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
<input
type="checkbox"
checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-indigo-600"
/>
Nur Hauptfilm rippen (längster Titel — Extras/Trailer bleiben weg)
</label>
)}
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */}
{deviceId && selectedType !== 'music' && (
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center justify-between px-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}>
<div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && (
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm.
</p>
)}
</div>
{scanStatus === 'running' ? (
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-indigo-500 border-t-transparent" />
Disc wird gelesen (20120 s)…
</span>
) : (
<button
onClick={scanStarten}
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</button>
)}
</div>
{scanStatus === 'error' && (
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p>
)}
{scanStatus === 'done' && titelListe.length === 0 && (
<p className={`text-xs px-3 py-2 ${theme === 'dark' ? 'text-amber-400' : 'text-amber-600'}`}>
Keine Titel lesbar — vermutlich kann diese Disc nicht entschlüsselt
werden (Details stehen nach einem Rip-Versuch im Job-Fehler).
</p>
)}
{scanStatus === 'done' && titelListe.length > 0 && (
<>
<div className="max-h-44 overflow-y-auto">
{titelListe.map(t => (
<label
key={t.nr}
className={`flex items-center gap-2.5 px-3 py-1.5 text-sm cursor-pointer ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700/50' : 'text-slate-700 hover:bg-slate-50'}`}
>
<input
type="checkbox"
checked={gewaehlt.has(t.nr)}
onChange={e => {
const neu = new Set(gewaehlt)
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
setGewaehlt(neu)
}}
className="w-4 h-4 accent-indigo-600 flex-shrink-0"
/>
<span className="font-mono text-xs w-14 flex-shrink-0">Titel {t.nr}</span>
<span className="flex-shrink-0">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && (
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{t.kapitel} Kap.</span>
)}
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span>
</label>
))}
</div>
<p className={`text-xs px-3 py-2 border-t ${theme === 'dark' ? 'border-slate-700 text-slate-500' : 'border-slate-200 text-slate-400'}`}>
{gewaehlt.size} von {titelListe.length} Titeln gewählt
{' · '}~{([...gewaehlt].reduce((s, nr) => s + (titelListe.find(t => t.nr === nr)?.groesse_bytes || 0), 0) / 1024 ** 3).toFixed(1)} GB
{' · '}Vorauswahl: alles ab 5 Minuten
</p>
</>
)}
</div>
)}
{/* Ordner-Browser */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
<button
onClick={() => setCustomPath(browsePath)}
className={`text-xs font-medium px-2.5 py-1 rounded transition-colors ${customPath === browsePath ? 'bg-indigo-600 text-white' : theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</button>
</div>
<div className="max-h-40 overflow-y-auto">
{browseDirs.length === 0 && browseFiles.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
) : (
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))}
{browseFiles.map(f => (
<div key={f.name} className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</div>
))}
</>
)}
</div>
</div>
{/* Gewähltes Ziel */}
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{customPath || targets.find(t => t.type === selectedType)?.path}
</span>
</div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}> <div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
<button <Button variant="secondary" onClick={onClose}>
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
Abbrechen Abbrechen
</button> </Button>
<button <Button variant="amber" onClick={handleSave}>
onClick={handleSave}
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors shadow-lg shadow-indigo-500/30"
>
Rippen starten Rippen starten
</button> </Button>
</div> </div>
</div> </div>
</div> </Modal>
) )
} }
+210 -192
View File
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react' import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen — import { Button } from './ui/Button'
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen. import { Input, Select } from './ui/Input'
interface Mount { interface Mount {
name: string name: string
@@ -42,7 +41,6 @@ export default function StorageMounts() {
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('') const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false) const [browserOffen, setBrowserOffen] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const browsen = async (pfad: string) => { const browsen = async (pfad: string) => {
@@ -151,211 +149,231 @@ export default function StorageMounts() {
} }
} }
const feld = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Vorhandene Ziele */} {/* Vorhandene Ziele */}
<div> <Card>
<div className="flex items-center justify-between mb-3"> <CardHeader>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3> <CardTitle className="flex items-center gap-2">
<button onClick={() => laden(true)} title="Ziele neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}> <HardDrive size={20} className="text-amber-500" />
<span>Verfügbare Ziele</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </Button>
</div> </CardHeader>
{targets.length === 0 ? ( <CardContent>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p> {targets.length === 0 ? (
) : ( <p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p>
<div className="space-y-2">
{targets.map(t => (
<div key={t.path} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
<div className="flex items-center gap-3">
<HardDrive size={18} className={t.is_mount ? 'text-emerald-500' : theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
<div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{t.name}</span>
<span className={`text-xs ml-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span>
</div>
</div>
{mounts.some(m => m.name === t.name) && (
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10">
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
)}
</div>
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet
und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<button
onClick={() => setBrowserOffen(!browserOffen)}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`}
>
<div>
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
Ordner-Verwaltung (optional)
</h3>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder
„doku", die danach im Rippen starten"-Dialog wählbar sind. Fürs normale
Rippen brauchst du das hier nicht.
</p>
</div>
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (<>
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
<input
value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner"
className={`w-32 px-2 py-1 text-xs border rounded ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/>
<button
onClick={ordnerAnlegen}
disabled={!neuerOrdner}
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`}
title="Ordner anlegen"
>
<FolderPlus size={16} />
</button>
</div>
<div className="max-h-36 overflow-y-auto">
{browseDirs.length === 0 && browseFiles.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
) : ( ) : (
<> <div className="space-y-2.5">
{browseDirs.map(d => ( {targets.map(t => (
<button <div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
key={d.path} <div className="flex items-center gap-3">
onClick={() => browsen(d.path)} <HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`} <div>
> <span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} /> <span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{d.name} {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</button> </span>
))} </div>
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte </div>
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */} {mounts.some(m => m.name === t.name) && (
{browseFiles.map(f => ( <button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<div <Trash2 size={16} />
key={f.name} </button>
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}
>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)} )}
</div> </div>
))} ))}
</> </div>
)} )}
</div> </CardContent>
</>)} </Card>
</div>
{/* Ordner-Verwaltung */}
<Card>
<button
onClick={() => setBrowserOffen(!browserOffen)}
className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
>
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ordner-Verwaltung (optional)
</h3>
<p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder „doku".
</p>
</div>
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span>
</button>
{browserOffen && (
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
<button
onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent}
className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<input
value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner"
className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/>
<button
onClick={ordnerAnlegen}
disabled={!neuerOrdner}
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Ordner anlegen"
>
<FolderPlus size={16} />
</button>
</div>
<div className="max-h-44 overflow-y-auto p-2">
{browseDirs.length === 0 && browseFiles.length === 0 ? (
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
) : (
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
>
<Folder size={15} className="text-slate-400 dark:text-slate-500" />
{d.name}
</button>
))}
{browseFiles.map(f => (
<div
key={f.name}
className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</div>
))}
</>
)}
</div>
</div>
)}
</Card>
{/* Neues Netzwerk-Ziel */} {/* Neues Netzwerk-Ziel */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}> <Card>
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <CardHeader>
<Plus size={18} className="text-indigo-500" /> Netzwerk-Ziel hinzufügen <CardTitle className="flex items-center gap-2">
</h3> <Plus size={20} className="text-amber-500" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <span>Netzwerk-Ziel hinzufügen</span>
<div> </CardTitle>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Name (klein, ohne Leerzeichen)</label> </CardHeader>
<input value={name} onChange={e => setName(e.target.value.toLowerCase())} className={feld} placeholder="nas-filme" /> <CardContent className="space-y-4">
</div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <Input
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</label> label="Name (klein, ohne Leerzeichen)"
<select value={typ} onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')} className={feld}> value={name}
onChange={e => setName(e.target.value.toLowerCase())}
placeholder="nas-filme"
/>
<Select
label="Typ"
value={typ}
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
>
<option value="nfs">NFS (NAS/Linux)</option> <option value="nfs">NFS (NAS/Linux)</option>
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option> <option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
</select> </Select>
</div>
{typ === 'nfs' ? ( {typ === 'nfs' ? (
<div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Quelle (host:/export/pfad)
</label>
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" />
</div>
) : (
<>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Rechner (dein PC oder NAS Name oder IP) label="Quelle (host:/export/pfad)"
</label> value={source}
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" /> onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</div> </div>
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.): ) : (
Windows/NAS lehnen Gast-Abfragen fast immer mit <>
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der
Knopf darunter scheinbar grundlos. */}
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label>
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" />
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
</div>
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben
versucht Rippy einen Gast-Zugriff, der meist mit Zugriff verweigert" endet.
</p>
<div className="md:col-span-2">
<button
onClick={freigabenAuflisten}
disabled={!host || sharesBusy}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</button>
</div>
{shares.length > 0 && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Freigabe wählen</label> <Input
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}> label="Rechner (dein PC oder NAS — Name oder IP)"
{shares.map(s => <option key={s} value={s}>{s}</option>)} value={host}
</select> onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC"
/>
</div> </div>
)} <Input
</> label="Benutzer"
value={username}
onChange={e => setUsername(e.target.value)}
autoComplete="off"
placeholder="Freigabe-Benutzer"
/>
<Input
label="Passwort"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
/>
<p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben versucht Rippy einen Gast-Zugriff.
</p>
<div className="md:col-span-2">
<Button
variant="secondary"
size="sm"
onClick={freigabenAuflisten}
disabled={!host || sharesBusy}
>
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</Button>
</div>
{shares.length > 0 && (
<div className="md:col-span-2">
<Select
label="Freigabe wählen"
value={selectedShare}
onChange={e => setSelectedShare(e.target.value)}
>
{shares.map(s => <option key={s} value={s}>{s}</option>)}
</Select>
</div>
)}
</>
)}
</div>
<Button
variant="amber"
onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
>
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</Button>
{feedback && (
<p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback}
</p>
)} )}
</div> </CardContent>
<button </Card>
onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
className="mt-4 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</button>
{feedback && (
<p className={`mt-3 text-sm flex items-center gap-2 ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback}
</p>
)}
</div>
</div> </div>
) )
} }
+114 -118
View File
@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react' import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden, import { Button } from './ui/Button'
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert import { Input } from './ui/Input'
// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -42,13 +39,8 @@ export default function WorkerVerwaltung() {
const [kopiert, setKopiert] = useState(false) const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux') const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
const [rippyHost, setRippyHost] = useState(window.location.hostname) const [rippyHost, setRippyHost] = useState(window.location.hostname)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
// Worker verbinden sich DIREKT mit Redis/Postgres (6379/5432) — das geht
// nur im Heimnetz. Wer das UI gerade über eine externe Domain (Reverse-
// Proxy) aufruft, würde sonst Befehle mit der falschen Adresse kopieren
// (Befund 24.07.: 401 vom Proxy statt Installer).
const siehtOeffentlichAus = const siehtOeffentlichAus =
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') && !rippyHost.endsWith('.local') &&
@@ -92,123 +84,127 @@ export default function WorkerVerwaltung() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Verbundene Worker */} {/* Verbundene Worker */}
<div> <Card>
<div className="flex items-center justify-between mb-3"> <CardHeader>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3> <CardTitle className="flex items-center gap-2">
<button onClick={() => laden(true)} title="Worker neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}> <Server size={20} className="text-amber-500" />
<span>Verbundene Worker</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </Button>
</div> </CardHeader>
{workers.length === 0 ? ( <CardContent>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p> {workers.length === 0 ? (
) : ( <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
<div className="space-y-2"> ) : (
{workers.map(w => ( <div className="space-y-2.5">
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> {workers.map(w => (
<div className="flex items-center gap-3 min-w-0"> <div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<span <div className="flex items-center gap-3 min-w-0">
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`} <span
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'} className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
/> title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} /> />
<div className="min-w-0"> <Server size={18} className="text-slate-400 dark:text-slate-500" />
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p> <div className="min-w-0">
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)} <p className="text-xs text-slate-500 dark:text-slate-400">
{w.info?.ip ? ` · IP ${w.info.ip}` : ''} {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''} {w.info?.ip ? ` · IP ${w.info.ip}` : ''}
</p> {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => (
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu')
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_LABELS[e] || e}
</span>
))}
</div>
{!w.online && (
<button
onClick={() => {
api.delete(`/workers/${encodeURIComponent(w.name)}`)
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}}
title="Verwaisten Eintrag entfernen"
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> ))}
<div className="flex flex-wrap gap-1 justify-end"> </div>
{w.encoders.map(e => ( )}
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> </CardContent>
{ENCODER_LABELS[e] || e} </Card>
</span>
))} {/* Neue Maschine anbinden */}
</div> <Card>
{!w.online && ( <CardHeader>
<button <CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
onClick={() => { </CardHeader>
api.delete(`/workers/${encodeURIComponent(w.name)}`) <CardContent className="space-y-4">
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() }) <div className="max-w-md">
.catch(() => toast('error', 'Entfernen fehlgeschlagen')) <Input
}} label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an" value={rippyHost}
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10" onChange={e => setRippyHost(e.target.value.trim())}
> placeholder="192.168.178.162"
<Trash2 size={14} /> />
</button> {siehtOeffentlichAus && (
)} <p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
</div> Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
</div> direkt mit Redis/Postgres (Ports 6379/5432) das geht nur im Heimnetz. Hier die <strong>LAN-IP</strong> eintragen, z. B. 192.168.178.162.
</p>
)}
</div>
<div className="flex gap-2">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<Button
key={wert}
variant={variante === wert ? 'amber' : 'secondary'}
size="sm"
onClick={() => setVariante(wert)}
>
{label}
</Button>
))} ))}
</div> </div>
)}
</div>
{/* Neue Maschine anbinden — zwei Varianten */} <p className="text-xs text-slate-500 dark:text-slate-400">
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}> {variante === 'linux'
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3> ? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch.'}
</p>
{/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/ <div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
Postgres — das geht nur im Heimnetz, nie über die externe Domain */} {installBefehl}
<div className="mb-3">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)
</label>
<input
value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())}
className={`w-full max-w-xs px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
placeholder="192.168.178.162"
/>
{siehtOeffentlichAus && (
<p className={`text-xs mt-1.5 px-2.5 py-1.5 rounded-lg inline-block ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
direkt mit Redis/Postgres (Ports 6379/5432) das geht nur im Heimnetz, und der
Proxy blockt den Installer-Download (401). Hier die <strong>LAN-IP</strong> der
Rippy-Maschine eintragen, z. B. 192.168.178.162.
</p>
)}
</div>
<div className="flex gap-2 mb-3">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<button <button
key={wert} onClick={kopieren}
onClick={() => setVariante(wert)} className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${ title="Kopieren"
variante === wert
? theme === 'dark' ? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
: theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500' : 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
}`}
> >
{label} {kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
</button> </button>
))} </div>
</div>
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
{variante === 'linux' Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, wo das Laufwerk hängt.
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.' </p>
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch und erzeugt: start-tray.bat (Tray-Symbol neben der Uhr mit Start/Stopp/Status), start-worker.bat (Konsole, für Fehlersuche) und uninstall.ps1 (entfernt alles rückstandsfrei inkl. Worker-Eintrag hier). Autostart bei Anmeldung: Parameter -Autostart anhängen.'} </CardContent>
</p> </Card>
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
{installBefehl}
<button
onClick={kopieren}
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
title="Kopieren"
>
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />}
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier,
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
</p>
</div>
</div> </div>
) )
} }
+39
View File
@@ -0,0 +1,39 @@
import { Disc } from 'lucide-react'
import { STATUS_STYLES, DISC_TYPE_STYLES } from '../../lib/design'
interface StatusBadgeProps {
status: string
className?: string
}
export function StatusBadge({ status, className = '' }: StatusBadgeProps) {
const config = STATUS_STYLES[status as keyof typeof STATUS_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: status,
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium border ${config.badge} ${className}`}>
{config.label}
</span>
)
}
interface TypeBadgeProps {
type: string
className?: string
}
export function TypeBadge({ type, className = '' }: TypeBadgeProps) {
const config = DISC_TYPE_STYLES[type as keyof typeof DISC_TYPE_STYLES] || {
badge: 'bg-slate-500/15 text-slate-400 border-slate-500/30',
label: type.toUpperCase(),
}
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium border ${config.badge} ${className}`}>
<Disc size={13} />
{config.label}
</span>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger' | 'amber'
size?: 'sm' | 'md' | 'lg'
children: ReactNode
className?: string
}
export function Button({
variant = 'primary',
size = 'md',
children,
className = '',
disabled,
...props
}: ButtonProps) {
const base = 'inline-flex items-center justify-center font-medium rounded-xl transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed active:scale-[0.98]'
const variants = {
primary: 'bg-indigo-600 hover:bg-indigo-500 text-white shadow-lg shadow-indigo-500/25 dark:shadow-indigo-900/40 focus:ring-indigo-500',
amber: 'bg-gradient-to-r from-amber-500 to-amber-600 hover:from-amber-400 hover:to-amber-500 text-slate-950 font-semibold shadow-lg shadow-amber-500/20 focus:ring-amber-500',
secondary: 'bg-slate-100 dark:bg-slate-800/80 hover:bg-slate-200 dark:hover:bg-slate-700/80 text-slate-700 dark:text-slate-200 border border-slate-200 dark:border-slate-700/80 focus:ring-slate-400',
ghost: 'bg-transparent hover:bg-slate-100 dark:hover:bg-slate-800/60 text-slate-600 dark:text-slate-300 focus:ring-slate-400',
danger: 'bg-rose-600/90 hover:bg-rose-500 text-white shadow-lg shadow-rose-500/20 focus:ring-rose-500',
}
const sizes = {
sm: 'px-2.5 py-1.5 text-xs gap-1.5',
md: 'px-4 py-2 text-sm gap-2',
lg: 'px-5 py-2.5 text-base gap-2.5',
}
return (
<button
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
disabled={disabled}
{...props}
>
{children}
</button>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { ReactNode } from 'react'
interface CardProps {
children: ReactNode
className?: string
glow?: boolean
onClick?: () => void
}
export function Card({ children, className = '', glow = false, onClick }: CardProps) {
return (
<div
onClick={onClick}
className={`rounded-2xl border transition-all duration-300 ${
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'
} bg-white/90 dark:bg-slate-900/80 backdrop-blur-md text-slate-900 dark:text-slate-100 ${
onClick ? 'cursor-pointer hover:border-slate-300 dark:hover:border-slate-700 hover:shadow-xl' : ''
} ${className}`}
>
{children}
</div>
)
}
export function CardHeader({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`px-6 py-4 border-b border-slate-200/80 dark:border-slate-800/80 flex items-center justify-between ${className}`}>
{children}
</div>
)
}
export function CardTitle({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<h2 className={`text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-100 ${className}`}>
{children}
</h2>
)
}
export function CardContent({ children, className = '' }: { children: ReactNode; className?: string }) {
return (
<div className={`p-6 ${className}`}>
{children}
</div>
)
}
+85
View File
@@ -0,0 +1,85 @@
import { InputHTMLAttributes, SelectHTMLAttributes, ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string
error?: string
className?: string
}
export function Input({ label, error, className = '', ...props }: InputProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<input
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 placeholder:text-slate-400 dark:placeholder:text-slate-600 ${
error ? 'border-rose-500 ring-rose-500/20' : ''
} ${className}`}
{...props}
/>
{error && <p className="text-xs text-rose-500">{error}</p>}
</div>
)
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string
children: ReactNode
className?: string
}
export function Select({ label, children, className = '', ...props }: SelectProps) {
return (
<div className="space-y-1.5 w-full">
{label && (
<label className="block text-xs font-medium text-slate-700 dark:text-slate-300">
{label}
</label>
)}
<select
className={`w-full px-3.5 py-2 rounded-xl text-sm border bg-white dark:bg-slate-900/90 text-slate-900 dark:text-slate-100 border-slate-200 dark:border-slate-700/80 focus:outline-none focus:ring-2 focus:ring-amber-500/50 focus:border-amber-500 transition-colors duration-200 ${className}`}
{...props}
>
{children}
</select>
</div>
)
}
interface ToggleProps {
checked: boolean
onChange: (checked: boolean) => void
label?: string
description?: string
disabled?: boolean
}
export function Toggle({ checked, onChange, label, description, disabled }: ToggleProps) {
return (
<div className="flex items-center justify-between py-2">
{(label || description) && (
<div className="space-y-0.5">
{label && <span className="text-sm font-medium text-slate-900 dark:text-slate-100">{label}</span>}
{description && <p className="text-xs text-slate-500 dark:text-slate-400">{description}</p>}
</div>
)}
<button
type="button"
disabled={disabled}
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-amber-500 focus:ring-offset-2 ${
checked ? 'bg-amber-500' : 'bg-slate-200 dark:bg-slate-700'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out ${
checked ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
</div>
)
}
+66
View File
@@ -0,0 +1,66 @@
import { ReactNode, useEffect } from 'react'
import { X } from 'lucide-react'
interface ModalProps {
isOpen: boolean
onClose: () => void
title?: string
children: ReactNode
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '4xl'
}
export function Modal({ isOpen, onClose, title, children, maxWidth = 'lg' }: ModalProps) {
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
if (isOpen) {
document.body.style.overflow = 'hidden'
window.addEventListener('keydown', handleKeyDown)
}
return () => {
document.body.style.overflow = 'unset'
window.removeEventListener('keydown', handleKeyDown)
}
}, [isOpen, onClose])
if (!isOpen) return null
const maxWidening = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'4xl': 'max-w-4xl',
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 overflow-y-auto">
{/* Glass Backdrop */}
<div
className="fixed inset-0 bg-slate-950/70 backdrop-blur-md transition-opacity duration-300 animate-in fade-in"
onClick={onClose}
/>
{/* Modal Dialog */}
<div className={`relative w-full ${maxWidening[maxWidth]} bg-white dark:bg-slate-900 rounded-2xl border border-slate-200 dark:border-slate-800 shadow-2xl shadow-black/40 overflow-hidden z-10 animate-in zoom-in-95 duration-200`}>
{title && (
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-900/50">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">{title}</h3>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-slate-400 hover:text-slate-600 dark:hover:text-slate-200 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
>
<X size={18} />
</button>
</div>
)}
<div className="p-6">
{children}
</div>
</div>
</div>
)
}
@@ -0,0 +1,19 @@
import { ReactNode } from 'react'
interface PageHeaderProps {
title: string
subtitle?: string
action?: ReactNode
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
return (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-8">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">{subtitle}</p>}
</div>
{action && <div>{action}</div>}
</div>
)
}
+19 -49
View File
@@ -2,57 +2,13 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root { @layer base {
--bg-color: #f8fafc; body {
--bg-secondary: #f1f5f9; @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;
--text-primary: #0f172a; }
--text-secondary: #64748b;
--accent-color: #6366f1;
--accent-hover: #4f46e5;
--border-color: #e2e8f0;
--card-bg: #ffffff;
--input-bg: #ffffff;
--input-border: #e2e8f0;
--success-color: #22c55e;
--error-color: #ef4444;
} }
[data-theme="dark"] { /* Toast Einflug */
--bg-color: #0f172a;
--bg-secondary: #1e293b;
--text-primary: #f1f5f9;
--text-secondary: #94a3b8;
--accent-color: #818cf8;
--accent-hover: #6366f1;
--border-color: #334155;
--card-bg: #1e293b;
--input-bg: #0f172a;
--input-border: #334155;
}
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--bg-color);
color: var(--text-primary);
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* Toast-Einflug (ToastContext): von rechts reinploppen */
@keyframes toast-rein { @keyframes toast-rein {
from { from {
opacity: 0; opacity: 0;
@@ -67,3 +23,17 @@ code {
.toast-rein { .toast-rein {
animation: toast-rein 0.25s ease-out; animation: toast-rein 0.25s ease-out;
} }
/* Subtile Disc-Rotation für Cinematic Spotlight */
@keyframes disc-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.animate-disc-spin {
animation: disc-spin 14s linear infinite;
}
+85
View File
@@ -0,0 +1,85 @@
// Centralized Design System & Style Tokens for Rippy (Cinematic Cinema OS)
export const STATUS_STYLES = {
pending: {
badge: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
label: 'Wartend',
},
processing: {
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30 animate-pulse',
label: 'Rippen',
},
transcoding: {
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
label: 'Komprimieren',
},
canceling: {
badge: 'bg-orange-500/15 text-orange-600 dark:text-orange-400 border-orange-500/30',
label: 'Wird abgebrochen…',
},
completed: {
badge: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30',
label: 'Fertig',
},
failed: {
badge: 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30',
label: 'Fehler',
},
} as const
export const DISC_TYPE_STYLES = {
cd: {
badge: 'bg-indigo-500/15 text-indigo-600 dark:text-indigo-400 border-indigo-500/30',
aura: 'from-indigo-500/20 via-indigo-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(99,102,241,0.2)]',
label: 'CD',
},
dvd: {
badge: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
aura: 'from-purple-500/20 via-purple-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(168,85,247,0.2)]',
label: 'DVD',
},
bluray: {
badge: 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30',
aura: 'from-sky-500/20 via-sky-900/10 to-transparent',
glow: 'shadow-[0_0_20px_-5px_rgba(14,165,233,0.2)]',
label: 'BLU-RAY',
},
uhd: {
badge: 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/40 font-semibold shadow-sm shadow-amber-500/20',
aura: 'from-amber-500/25 via-amber-900/15 to-transparent',
glow: 'shadow-[0_0_25px_-5px_rgba(245,158,11,0.25)]',
label: '4K UHD',
},
} as const
export const LOG_LEVEL_STYLES = {
INFO: {
color: 'text-sky-600 dark:text-sky-400',
bg: 'bg-sky-500/10',
border: 'border-sky-500/20',
},
WARNING: {
color: 'text-amber-600 dark:text-amber-400',
bg: 'bg-amber-500/10',
border: 'border-amber-500/20',
},
ERROR: {
color: 'text-rose-600 dark:text-rose-400',
bg: 'bg-rose-500/10',
border: 'border-rose-500/20',
},
DEBUG: {
color: 'text-slate-500 dark:text-slate-400',
bg: 'bg-slate-500/10',
border: 'border-slate-500/20',
},
} as const
export const ENCODER_BADGES: Record<string, { label: string; highlight: boolean }> = {
'cpu-x264': { label: 'H.264 (CPU)', highlight: false },
'cpu-x265': { label: 'H.265 (CPU)', highlight: false },
'vaapi': { label: 'VAAPI ⚡', highlight: true },
'nvenc': { label: 'NVENC ⚡', highlight: true },
}
+19 -23
View File
@@ -1,37 +1,33 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react' import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext' import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) { function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
const { theme } = useDarkMode()
return ( return (
<section className={`rounded-xl border p-6 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card>
<h2 className={`text-lg font-semibold mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <CardHeader>
<Icon size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <CardTitle className="flex items-center gap-2.5">
{titel} <Icon size={20} className="text-amber-500" />
</h2> <span>{titel}</span>
<div className={`space-y-2 text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{children} {children}
</div> </CardContent>
</section> </Card>
) )
} }
export default function AnleitungPage() { export default function AnleitungPage() {
const { theme } = useDarkMode() const fett = "font-semibold text-slate-900 dark:text-slate-100"
const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
return ( return (
<div className="space-y-6 max-w-4xl transition-colors duration-300"> <div className="space-y-6 max-w-4xl">
<div className="flex flex-col gap-2"> <PageHeader
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Anleitung</h1> title="Anleitung & Hilfe"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
So funktioniert Rippy vom Einlegen bis zum fertigen Film in deinem Media-Server. />
</p>
</div>
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig"> <Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
<p> <p>
+193 -255
View File
@@ -1,12 +1,16 @@
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 } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
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 { Button } from '../components/ui/Button'
import { StatusBadge, TypeBadge } from '../components/ui/Badge'
import ConfirmDialog from '../components/ConfirmDialog' 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 { ENCODER_BADGES } from '../lib/design'
interface Job { interface Job {
id: string id: string
@@ -29,105 +33,19 @@ async function fetchJobs(): Promise<Job[]> {
} }
} }
function StatusBadge({ status }: { status: string }) {
const { theme } = useDarkMode()
const styles = theme === 'dark'
? {
pending: 'bg-amber-900/30 text-amber-400 ring-amber-700/20',
processing: 'bg-blue-900/30 text-blue-400 ring-blue-700/20',
transcoding: 'bg-purple-900/30 text-purple-400 ring-purple-700/20',
canceling: 'bg-orange-900/30 text-orange-400 ring-orange-700/20',
completed: 'bg-emerald-900/30 text-emerald-400 ring-emerald-700/20',
failed: 'bg-rose-900/30 text-rose-400 ring-rose-700/20',
}
: {
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
transcoding: 'bg-purple-100 text-purple-700 ring-purple-600/20',
canceling: 'bg-orange-100 text-orange-700 ring-orange-600/20',
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
}
const labels = {
pending: 'Wartend',
processing: 'Rippen',
transcoding: 'Komprimieren',
canceling: 'Wird abgebrochen…',
completed: 'Fertig',
failed: 'Fehler',
}
return (
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ring-1 ring-inset ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-700 ring-gray-600/10'}`}>
{labels[status as keyof typeof labels] || status}
</span>
)
}
function TypeBadge({ type }: { type: string }) {
const { theme } = useDarkMode()
// 4K UHD sichtbar abheben (Commander-Wunsch 24.07.) — es IST eine Blu-ray,
// aber der Unterschied (Laufwerk! Größe!) muss auf einen Blick erkennbar sein.
const colors: Record<string, string> = theme === 'dark'
? {
cd: 'bg-blue-900/30 text-blue-400',
dvd: 'bg-purple-900/30 text-purple-400',
bluray: 'bg-pink-900/30 text-pink-400',
uhd: 'bg-amber-900/30 text-amber-400',
}
: {
cd: 'bg-blue-100 text-blue-600',
dvd: 'bg-purple-100 text-purple-600',
bluray: 'bg-pink-100 text-pink-600',
uhd: 'bg-amber-100 text-amber-700',
}
const labels: Record<string, string> = {
cd: 'CD', dvd: 'DVD', bluray: 'BLU-RAY', uhd: '4K UHD',
}
const fallback = theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-100 text-slate-700'
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium ${colors[type] || fallback}`}>
<Disc size={14} />
{labels[type] || type.toUpperCase()}
</span>
)
}
function ProgressBar({ progress }: { progress: number }) { function ProgressBar({ progress }: { progress: number }) {
const { theme } = useDarkMode()
const bgColors = theme === 'dark'
? {
empty: 'bg-slate-700',
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
done: 'bg-emerald-500',
}
: {
empty: 'bg-slate-200',
indicator: 'bg-gradient-to-r from-indigo-500 to-purple-600',
done: 'bg-emerald-500',
}
const textColors = theme === 'dark'
? {
label: 'text-slate-400',
value: 'text-slate-200',
}
: {
label: 'text-slate-500',
value: 'text-slate-900',
}
return ( return (
<div className="w-32"> <div className="w-32">
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<span className={`text-xs font-medium ${textColors.label}`}>Fortschritt</span> <span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
<span className={`text-xs font-medium ${textColors.value}`}>{progress}%</span> <span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
</div> </div>
<div className={`w-full ${bgColors.empty} rounded-full h-2 overflow-hidden`}> <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 <div
className={`h-2 rounded-full transition-all duration-500 ease-out ${ className={`h-2 rounded-full transition-all duration-500 ease-out ${
progress === 100 ? bgColors.done : bgColors.indicator 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}%` }} style={{ width: `${progress}%` }}
/> />
@@ -141,13 +59,6 @@ interface WorkerInfo {
encoders: string[] encoders: string[]
} }
const ENCODER_KURZ: Record<string, string> = {
'cpu-x264': 'H.264 (CPU)',
'cpu-x265': 'H.265 (CPU)',
'vaapi': 'VAAPI ⚡',
'nvenc': 'NVENC ⚡',
}
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[]>([])
@@ -155,7 +66,6 @@ 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 { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const jobLoeschen = async (job: Job) => { const jobLoeschen = async (job: Job) => {
@@ -207,153 +117,219 @@ export default function Dashboard() {
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]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-amber-500"></div>
</div> </div>
) )
} }
return ( return (
<div className="space-y-8 transition-colors duration-300"> <div className="space-y-8">
{/* Header */} <PageHeader
<div className="flex flex-col gap-2 transition-colors duration-300"> title="Dashboard"
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Dashboard</h1> subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p> />
</div>
{/* System-Leiste: Encoder + aktiver Job auf einen Blick */} {/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
<div className={`flex flex-wrap items-center gap-x-6 gap-y-2 rounded-xl border px-5 py-3 text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> {aktiverJob && (
<div className="flex items-center gap-2"> <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">
<Activity size={16} className="text-indigo-500" /> <div className="flex flex-col md:flex-row items-center gap-6">
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span> <div className="relative flex-shrink-0">
{workers.length === 0 ? ( <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">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Worker</span> <div className="w-full h-full rounded-full border-4 border-dashed border-white/40 flex items-center justify-center">
) : ( <Disc size={36} className="text-white" />
workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => ( </div>
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> </div>
{ENCODER_KURZ[e] || e} <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> </span>
)) </div>
)}
</div> <div className="flex-1 min-w-0 text-center md:text-left space-y-2">
<div className="flex items-center gap-2"> <div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
<Clock size={16} className="text-indigo-500" /> <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">
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span> {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
{aktiverJob ? ( </span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <TypeBadge type={aktiverJob.type} />
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % </div>
</span> <h2 className="text-2xl font-bold text-slate-100 truncate">
) : ( {aktiverJob.title || 'Aktiver Job'}
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span> </h2>
)} <p className="text-xs text-slate-400">
</div> Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
{plaetze.length > 0 && ( </p>
<div className="flex items-center gap-2">
<HardDrive size={16} className="text-indigo-500" /> <div className="pt-2">
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span> <div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
{plaetze.map(p => ( <span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
<span <span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
key={p.name} </div>
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`} <div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
className={`text-xs px-2 py-0.5 rounded ${p.frei_gb < 60 ? 'bg-amber-600/20 text-amber-500 font-medium' : theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`} <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">
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
</Button>
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
> >
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB Abbrechen
</span> </Button>
))} </div>
</div> </div>
)} </Card>
</div> )}
{/* Laufwerke + erkannte Disc */} {/* System-Telemetrie Leiste */}
<Card>
<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-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Encoder:</span>
{workers.length === 0 ? (
<span className="text-slate-400 dark: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-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_BADGES[e]?.label || e}
</span>
))
)}
</div>
<div className="flex items-center gap-2.5">
<Clock size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
{aktiverJob ? (
<span className="text-slate-900 dark:text-slate-200 font-medium">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span>
) : (
<span className="text-slate-400 dark:text-slate-500 italic">kein Job</span>
)}
</div>
{plaetze.length > 0 && (
<div className="flex items-center gap-2.5 ml-auto">
<HardDrive size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
{plaetze.map(p => (
<span
key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
className={`text-xs px-2.5 py-0.5 rounded-md font-mono border ${
p.frei_gb < 60
? 'bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30 font-semibold'
: 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
}`}
>
{p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span>
))}
</div>
)}
</CardContent>
</Card>
{/* Laufwerke & Erkennung */}
<DeviceDiscovery /> <DeviceDiscovery />
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
icon={Clock} icon={Clock}
label="Warteschlange" label="Warteschlange"
value={stats.pending.toString()} value={stats.pending.toString()}
color="amber" badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20"
darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={Activity} icon={Activity}
label="Bearbeitung" label="Bearbeitung"
value={stats.processing.toString()} value={stats.processing.toString()}
color="blue" badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20"
darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={CheckCircle} icon={CheckCircle}
label="Fertig" label="Fertig"
value={stats.completed.toString()} value={stats.completed.toString()}
color="emerald" badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20"
darkMode={theme === 'dark'}
/> />
<StatCard <StatCard
icon={AlertCircle} icon={AlertCircle}
label="Fehler" label="Fehler"
value={stats.failed.toString()} value={stats.failed.toString()}
color="rose" badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20"
darkMode={theme === 'dark'}
/> />
</div> </div>
{/* Recent Jobs */} {/* Recent Jobs Table */}
{/* Schnellaktionen + doppelte Geräte-Sektion sind raus (23.07.): alle <Card className="overflow-hidden">
vier Knöpfe und der zweite Details-Button waren funktionslos — <CardHeader>
die echte Geräte-Steuerung lebt in DeviceDiscovery oben. */} <CardTitle>Neueste Jobs</CardTitle>
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Neueste Jobs</h2>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{jobs.length} Jobs insgesamt</span> <span className="text-xs text-slate-500 dark:text-slate-400">{jobs.length} Jobs insgesamt</span>
{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 (öffnet in Excel)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`} 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} />
CSV CSV
</a> </a>
)} )}
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && ( {jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
<button <Button
variant="secondary"
size="sm"
onClick={() => setAufraeumenOffen(true)} onClick={() => setAufraeumenOffen(true)}
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)" title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
> >
<Trash2 size={13} /> <Trash2 size={13} />
Erledigte aufräumen Erledigte aufräumen
</button> </Button>
)} )}
</div> </div>
</div> </CardHeader>
<div className="overflow-x-auto transition-colors duration-300">
<table className="w-full transition-colors duration-300"> <div className="overflow-x-auto">
<thead className={theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}> <table className="w-full">
<thead className="bg-slate-50 dark:bg-slate-950/80 border-b border-slate-200/80 dark:border-slate-800">
<tr> <tr>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Typ</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Titel</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Titel</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Status</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Status</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Gerät</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Gerät</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fortschritt</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Fortschritt</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Startzeit</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Startzeit</th>
<th className={`px-6 py-3 text-left text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Aktion</th> <th className="px-6 py-3.5 text-left text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Aktion</th>
</tr> </tr>
</thead> </thead>
<tbody className={`divide-y ${theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200'}`}> <tbody className="divide-y divide-slate-200/80 dark:divide-slate-800">
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<tr> <tr>
<td colSpan={7} className="px-6 py-12 text-center transition-colors duration-300"> <td colSpan={7} className="px-6 py-12 text-center">
<div className={`mb-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-400'}`}>Noch keine Jobs</div> <div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p> <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> </td>
</tr> </tr>
) : ( ) : (
jobs.slice(0, 10).map(job => ( jobs.slice(0, 10).map(job => (
<tr key={job.id} className={`transition-colors duration-300 ${theme === 'dark' ? 'hover:bg-slate-700/40' : 'hover:bg-slate-50'}`}> <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} />
</td> </td>
@@ -361,7 +337,7 @@ export default function Dashboard() {
<button <button
onClick={() => setDetailJobId(job.id)} onClick={() => setDetailJobId(job.id)}
title="Details zu diesem Titel anzeigen" title="Details zu diesem Titel anzeigen"
className={`text-sm font-medium text-left hover:underline transition-colors ${theme === 'dark' ? 'text-slate-100 hover:text-indigo-300' : 'text-slate-900 hover:text-indigo-600'}`} 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'}
</button> </button>
@@ -369,58 +345,59 @@ export default function Dashboard() {
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} /> <StatusBadge status={job.status} />
</td> </td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-slate-500 dark:text-slate-400">
{job.device} {job.device}
</td> </td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} /> <ProgressBar progress={job.progress} />
</td> </td>
<td className={`px-6 py-4 whitespace-nowrap text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <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)} onClick={() => setDetailJobId(job.id)}
title="Fertige Dateien herunterladen (öffnet die Job-Details)" title="Fertige Dateien herunterladen (öffnet Job-Details)"
className={`inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-emerald-300 hover:bg-slate-600' : 'bg-slate-100 text-emerald-700 hover:bg-slate-200'}`}
> >
<Download size={13} /> <Download size={13} className="text-emerald-500" />
Download Download
</button> </Button>
)} )}
{/* Nur wenn Rohdaten wirklich daliegen — an einem Job,
der nie gerippt hat, war der Knopf Unsinn */}
{job.status === 'failed' && job.can_retry && ( {job.status === 'failed' && job.can_retry && (
<button <Button
variant="secondary"
size="sm"
onClick={() => { onClick={() => {
api.post(`/jobs/${job.id}/retry-transcode`) api.post(`/jobs/${job.id}/retry-transcode`)
.then(() => toast('success', 'Kompression neu eingereiht')) .then(() => toast('success', 'Kompression neu eingereiht'))
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen')) .catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
}} }}
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)" title="Kompression neu anstoßen"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-100 text-indigo-700 hover:bg-slate-200'}`}
> >
Neu komprimieren 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 — fertige Dateien bleiben liegen" title="Job aus der Liste entfernen"
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`} 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') && ( {(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
<button <Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})} onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
title="Job abbrechen (Rohdaten bleiben erhalten)" title="Job abbrechen"
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-rose-300 hover:bg-slate-600' : 'bg-slate-100 text-rose-700 hover:bg-slate-200'}`}
> >
Abbrechen Abbrechen
</button> </Button>
)} )}
</td> </td>
</tr> </tr>
@@ -429,12 +406,10 @@ export default function Dashboard() {
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </Card>
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
<LiveLogSection /> <LiveLogSection />
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} /> <JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
<ConfirmDialog <ConfirmDialog
@@ -450,55 +425,18 @@ export default function Dashboard() {
) )
} }
function StatCard({ icon: Icon, label, value, color, darkMode }: { icon: any, label: string, value: string, color: string, darkMode: boolean }) { function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
const { theme } = useDarkMode()
const bgColors = darkMode
? {
card: 'bg-slate-800 border-slate-700',
iconBg: {
amber: 'bg-amber-700',
blue: 'bg-blue-700',
emerald: 'bg-emerald-700',
rose: 'bg-rose-700',
},
iconText: {
amber: 'text-amber-300',
blue: 'text-blue-300',
emerald: 'text-emerald-300',
rose: 'text-rose-300',
},
textLabel: 'text-slate-400',
textValue: 'text-slate-200',
}
: {
card: 'bg-white border-slate-200',
iconBg: {
amber: 'bg-amber-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
rose: 'bg-rose-500',
},
iconText: {
amber: 'text-amber-600',
blue: 'text-blue-600',
emerald: 'text-emerald-600',
rose: 'text-rose-600',
},
textLabel: 'text-slate-500',
textValue: 'text-slate-900',
}
return ( return (
<div className={`${bgColors.card} rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow`}> <Card>
<div className="flex items-center gap-4"> <CardContent className="p-5 flex items-center gap-4">
<div className={`${bgColors.iconBg[color as keyof typeof bgColors.iconBg]} p-3 rounded-lg`}> <div className={`p-3 rounded-xl border ${badgeStyle}`}>
<Icon className={`${bgColors.iconText[color as keyof typeof bgColors.iconText]} w-6 h-6`} /> <Icon className="w-6 h-6" />
</div> </div>
<div> <div>
<p className={`text-sm font-medium ${bgColors.textLabel}`}>{label}</p> <p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
<p className={`text-3xl font-bold ${bgColors.textValue}`}>{value}</p> <p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
</div> </div>
</div> </CardContent>
</div> </Card>
) )
} }
+93 -126
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react' import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
interface LogEntry { interface LogEntry {
id: string id: string
@@ -18,12 +20,9 @@ export default function LogsPage() {
const [filter, setFilter] = useState<string>('all') const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const refreshLogs = async (manuell = false) => { const refreshLogs = async (manuell = false) => {
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
if (manuell) setRefreshing(true) if (manuell) setRefreshing(true)
try { try {
const response = await api.get('/logs', { params: { limit: 200 } }) const response = await api.get('/logs', { params: { limit: 200 } })
@@ -46,19 +45,19 @@ export default function LogsPage() {
const getLevelColor = (level: string) => { const getLevelColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20' case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20' case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20' case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20' default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
} }
} }
const getLevelIcon = (level: string) => { const getLevelIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
@@ -83,153 +82,121 @@ export default function LogsPage() {
} }
return ( return (
<div className="space-y-6 transition-colors duration-300"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex flex-col gap-2 transition-colors duration-300"> title="System-Logs"
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1> subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p> action={
</div> <Button variant="secondary" onClick={() => refreshLogs(true)}>
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
Neu laden
</Button>
}
/>
{/* Stats */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card>
<div className="flex items-center gap-3 mb-2"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500"> <div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
<Terminal size={18} /> <Terminal size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
</div> </div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> </CardContent>
<div className="flex items-center gap-3 mb-2"> </Card>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
</div> </div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> </CardContent>
<div className="flex items-center gap-3 mb-2"> </Card>
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
<CheckCircle size={18} /> <CheckCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
</div> </div>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> </CardContent>
<div className="flex items-center gap-3 mb-2"> </Card>
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
</div> </div>
</CardContent>
</Card>
</div> </div>
{/* Filters */} {/* Filters & Log List */}
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className="flex flex-wrap items-center gap-3"> <div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span> <span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
<button {['all', 'info', 'success', 'warning', 'error'].map(f => (
onClick={() => setFilter('all')} <button
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ key={f}
filter === 'all' onClick={() => setFilter(f)}
? 'bg-indigo-600 text-white' className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' filter === f
}`} ? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/20'
> : 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
Alle }`}
</button> >
<button {f === 'all' ? 'Alle' : getLevelLabel(f)}
onClick={() => setFilter('info')} </button>
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ ))}
filter === 'info'
? 'bg-blue-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Info
</button>
<button
onClick={() => setFilter('success')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'success'
? 'bg-emerald-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Erfolg
</button>
<button
onClick={() => setFilter('warning')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'warning'
? 'bg-amber-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Warnung
</button>
<button
onClick={() => setFilter('error')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'error'
? 'bg-rose-600 text-white'
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Fehler
</button>
<div className="flex-1" />
<button
onClick={() => refreshLogs(true)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
>
<RefreshCw size={14} className={loading || refreshing ? 'animate-spin' : ''} />
Neu laden
</button>
</div> </div>
</div>
{/* Logs List */}
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="max-h-[600px] overflow-y-auto"> <div className="max-h-[600px] overflow-y-auto">
{filteredLogs.length === 0 ? ( {filteredLogs.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300"> <div className="text-center py-16">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <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 border border-slate-200 dark:border-slate-700">
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} /> <Terminal size={32} />
</div> </div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p> <p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-slate-200 dark:divide-slate-700"> <div className="divide-y divide-slate-200/80 dark:divide-slate-800">
{filteredLogs.map(log => ( {filteredLogs.map(log => (
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}> <div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
{getLevelIcon(log.level)} {getLevelIcon(log.level)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
{log.source} {log.source}
</span> </span>
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <span className="text-xs text-slate-400 font-mono">
{log.timestamp} {log.timestamp}
</span> </span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}> <span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
{getLevelLabel(log.level)} {getLevelLabel(log.level)}
</span> </span>
</div> </div>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{log.message} {log.message}
</p> </p>
{log.details && ( {log.details && (
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}> <p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
{log.details} {log.details}
</p> </p>
)} )}
@@ -240,7 +207,7 @@ export default function LogsPage() {
</div> </div>
)} )}
</div> </div>
</div> </Card>
</div> </div>
) )
} }
File diff suppressed because it is too large Load Diff