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
+36 -28
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>
<div className="px-3 mb-2">
<button <button
onClick={toggleTheme} onClick={toggleTheme}
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" 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' : 'Light Mode'} title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
> >
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} <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> </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) => {
const isActive = page === item.id
return (
<button <button
key={item.id} key={item.id}
onClick={() => setPage(item.id as Page)} onClick={() => setPage(item.id as Page)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
page === item.id isActive
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30' ? '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 hover:text-white' : 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent'
}`} }`}
> >
<item.icon size={20} /> <item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} />
<span className="font-medium">{item.label}</span> <span>{item.label}</span>
</button> </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 />}
+28 -38
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 */}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className="flex items-center gap-3">
{getIcon()} {getIcon()}
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{title} {title}
</h2> </h2>
</div> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</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>
</div>
{/* Footer */} <div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<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 variant="secondary" onClick={onClose}>
<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} {cancelText}
</button> </Button>
<button <Button variant={getConfirmVariant()} onClick={onConfirm}>
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
>
{confirmText} {confirmText}
</button> </Button>
</div>
</div>
</div> </div>
</Modal>
) )
} }
+80 -124
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>
<button
onClick={() => refreshDevices(true)}
className={`p-2 rounded-lg transition-colors ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-600'}`}
title="Geräte neu laden"
>
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
</button>
</div> </div>
<Button variant="ghost" size="sm" onClick={() => refreshDevices(true)} title="Geräte neu laden">
<RefreshCw size={18} className={loading || refreshing ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<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>
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
Im Laufwerk erkannt Im Laufwerk erkannt
</p> </p>
<h3 className={`text-xl font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="text-xl font-bold truncate text-slate-900 dark:text-slate-100">
{device.disc!.title} {device.disc!.title}
{device.disc!.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({device.disc!.year})</span> : null} {device.disc!.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({device.disc!.year})</span> : null}
</h3> </h3>
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${getTypeColor(device.type)}`}> <TypeBadge type={device.type} />
{device.disc!.disc_type?.toUpperCase()} <span className="text-xs text-slate-500 dark:text-slate-400">
</span>
<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> </div>
{device.disc!.metadata?.overview && ( {device.disc!.metadata?.overview && (
<p className={`text-sm mt-2 line-clamp-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-600'}`}> <p className="text-sm mt-2 line-clamp-2 text-slate-600 dark:text-slate-300">
{device.disc!.metadata.overview} {device.disc!.metadata.overview}
</p> </p>
)} )}
{device.disc!.bereits_gerippt && ( {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'}`}> <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" /> <AlertCircle size={13} className="flex-shrink-0" />
Diese Disc wurde bereits gerippt Diese Disc wurde bereits gerippt
{device.disc!.bereits_gerippt.finished_at {device.disc!.bereits_gerippt.finished_at
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})` ? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
: ''} nochmal rippen ist trotzdem möglich. : ''} nochmal rippen ist möglich.
</p> </p>
)} )}
<div className="flex items-center gap-3 mt-3"> </div>
<button <div className="flex items-center gap-3 mt-4">
onClick={() => setModalDevice(device)} <Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
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>
+55 -75
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,45 +72,40 @@ 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'}`}>
{detail?.title || 'Job-Details'}
</h2>
<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>
{laedt ? ( {laedt ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-indigo-500" size={28} /> <Loader2 className="animate-spin text-amber-500" size={28} />
</div> </div>
) : !detail ? ( ) : !detail ? (
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
Job nicht gefunden. Job nicht gefunden.
</p> </p>
) : ( ) : (
<div className="p-6 space-y-5"> <div className="space-y-6">
{/* Film/Serien-Info aus der Disc-Erkennung */} {/* Film/Serien-Info aus der Disc-Erkennung */}
<div className="flex gap-5"> <div className="flex gap-5">
{poster ? ( {poster ? (
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" /> <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={`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'}`}> <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">
<Film size={32} /> <Film size={36} />
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{detail.title || 'Unbekannt'} {detail.title || 'Unbekannt'}
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null} {meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
</p> </h3>
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <StatusBadge status={detail.status} />
</div>
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
{meta?.type === 'tv' ? 'Serie' : 'Film'} {meta?.type === 'tv' ? 'Serie' : 'Film'}
{meta?.source ? ` · Quelle: ${meta.source}` : ''} {meta?.source ? ` · Quelle: ${meta.source}` : ''}
{meta?.runtime ? ` · ${meta.runtime} min` : ''} {meta?.runtime ? ` · ${meta.runtime} min` : ''}
@@ -132,61 +113,62 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
{meta?.genres && meta.genres.length > 0 && ( {meta?.genres && meta.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{meta.genres.map(g => ( {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> <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">
{g}
</span>
))} ))}
</div> </div>
)} )}
{meta?.overview ? ( {meta?.overview ? (
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> <p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
{meta.overview} {meta.overview}
</p> </p>
) : ( ) : (
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <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. Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Nüchterne Job-Fakten */} {/* Job-Fakten Grid */}
<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'}`}> <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">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''} {detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
</span> </span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span> <span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'} {detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
</span> </span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'} {detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
</span> </span>
{(detail.output_path || detail.target_dir) && ( {(detail.output_path || detail.target_dir) && (
<> <>
<span className={`flex items-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}><FolderOpen size={13} /> Ablage</span> <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 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}> <span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
{detail.output_path || detail.target_dir} {detail.output_path || detail.target_dir}
</span> </span>
</> </>
)} )}
</div> </div>
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */} {/* Dateiliste & Download */}
{detail.status === 'completed' && dateien && dateien.length > 0 && ( {detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <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-2"> <div className="flex items-center justify-between mb-3">
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}> <p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
<Download size={15} /> Dateien herunterladen <Download size={15} className="text-amber-500" /> Dateien herunterladen
</p> </p>
{dateien.length > 1 && ( {dateien.length > 1 && (
<button <Button
variant="amber"
size="sm"
onClick={() => { 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) => { dateien.forEach((f, i) => {
setTimeout(() => { setTimeout(() => {
const a = document.createElement('a') const a = document.createElement('a')
@@ -198,24 +180,23 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
}, i * 400) }, 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}) Alle herunterladen ({dateien.length})
</button> </Button>
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1.5">
{dateien.map(f => ( {dateien.map(f => (
<a <a
key={f.name} key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`} href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download 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'}`} 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" /> <Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span> <span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && ( {f.size_mb != null && (
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <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`} {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span> </span>
)} )}
@@ -226,14 +207,13 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
)} )}
{detail.error && ( {detail.error && (
<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 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" /> <AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
<span className="break-words min-w-0">{detail.error}</span> <span className="break-words min-w-0">{detail.error}</span>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </Modal>
</div>
) )
} }
+29 -34
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 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p> <p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-lg font-bold text-slate-900 dark:text-slate-100">
{currentJob.progress}% {currentJob.progress}%
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p> <p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.device || 'Unbekannt'} {currentJob.device || 'Unbekannt'}
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p> <p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.type?.toUpperCase() || 'Unbekannt'} {currentJob.type?.toUpperCase() || 'Unbekannt'}
</p> </p>
</div> </div>
</> </div>
)} )}
</div> </Card>
</div>
) )
} }
+30 -37
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,63 +71,60 @@ 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'}`}> >
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400">
Deine Wahl gilt für diese Disc auch beim nächsten Einlegen. Deine Wahl gilt für diese Disc auch beim nächsten Einlegen.
</p> </p>
</div>
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<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 </div>
<Button
variant="amber"
onClick={suchen} onClick={suchen}
disabled={!suchbegriff || laeuft} 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} />} {laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen Suchen
</button> </Button>
</div> </div>
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>} {fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
{kandidaten.length > 0 && ( {kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4"> <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) => ( {kandidaten.map((k, i) => (
<button <button
key={i} key={i}
onClick={() => waehlen(k)} 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'}`} 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 ? ( {k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" /> <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 ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}> <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} /> <Film size={32} />
</div> </div>
)} )}
<div className="p-2"> <div className="p-2.5">
<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 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">
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> {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} {k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p> </p>
</div> </div>
@@ -139,7 +133,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
</div> </div>
)} )}
</div> </div>
</div> </Modal>
</div>
) )
} }
+82 -136
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,136 +153,113 @@ 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'}`}> >
<div className="space-y-5">
<p className="text-xs text-slate-500 dark:text-slate-400">
Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar. Schnellwahl oder eigenen Ordner wählen Netzwerk-Ziele (NAS, PC) sind unter Einstellungen Speicherziele einhängbar.
</p> </p>
</div>
{/* Body */}
<div className="p-6 space-y-5">
{/* Schnellwahl */} {/* Schnellwahl */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{targets.map((t) => ( {targets.map((t) => {
const isSelected = selectedType === t.type && !customPath
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>
</span>
<span className="text-sm font-medium">{t.name}</span>
</button> </button>
))} )
})}
</div> </div>
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden {/* Serien-Flow */}
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
{selectedType === 'series' && ( {selectedType === 'series' && (
<div className={`rounded-lg p-3 space-y-2 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <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"> <div className="grid grid-cols-[1fr_90px] gap-3">
<div> <Input
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label> label="Serienname"
<input
value={serienName} value={serienName}
onChange={e => setSerienName(e.target.value)} onChange={e => setSerienName(e.target.value)}
placeholder="z. B. Neon Genesis Evangelion" 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> <Input
<div> label="Staffel"
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
<input
type="number" type="number"
min={1} min={1}
value={staffel} value={staffel}
onChange={e => setStaffel(parseInt(e.target.value) || 1)} 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>
</div> <p className="text-xs text-slate-500 dark:text-slate-400">
<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')}
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> </p>
</div> </div>
)} )}
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung); {/* Hauptfilm-Wahl */}
eine explizite Titel-Auswahl unten schlägt diese Option */}
{selectedType === 'movies' && scanStatus !== 'done' && ( {selectedType === 'movies' && scanStatus !== 'done' && (
<label className={`flex items-center gap-2.5 text-sm px-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <label className="flex items-center gap-3 text-sm text-slate-700 dark:text-slate-300">
<input <input
type="checkbox" type="checkbox"
checked={nurHauptfilm} checked={nurHauptfilm}
onChange={e => setNurHauptfilm(e.target.checked)} onChange={e => setNurHauptfilm(e.target.checked)}
className="w-4 h-4 accent-indigo-600" className="w-4 h-4 accent-amber-500 rounded"
/> />
Nur Hauptfilm rippen (längster Titel Extras/Trailer bleiben weg) Nur Hauptfilm rippen (längster Titel Extras/Trailer bleiben weg)
</label> </label>
)} )}
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */} {/* Titel-Auswahl (Track-Tabelle) */}
{deviceId && selectedType !== 'music' && ( {deviceId && selectedType !== 'music' && (
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <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-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}> <div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
<div> <div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Titel-Auswahl (optional)</span> <span className="text-sm font-medium text-slate-900 dark:text-slate-200">Titel-Auswahl (optional)</span>
{scanStatus === 'idle' && ( {scanStatus === 'idle' && (
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm. Disc scannen und einzelne Titel an-/abwählen.
</p> </p>
)} )}
</div> </div>
{scanStatus === 'running' ? ( {scanStatus === 'running' ? (
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <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-indigo-500 border-t-transparent" /> <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) Disc wird gelesen (20120 s)
</span> </span>
) : ( ) : (
<button <Button variant="secondary" size="sm" onClick={scanStarten}>
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'} {scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
</button> </Button>
)} )}
</div> </div>
{scanStatus === 'error' && ( {scanStatus === 'error' && (
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p> <p className="text-xs text-rose-500 px-4 py-2.5">{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 && ( {scanStatus === 'done' && titelListe.length > 0 && (
<> <>
<div className="max-h-44 overflow-y-auto"> <div className="max-h-44 overflow-y-auto">
{titelListe.map(t => ( {titelListe.map(t => (
<label <label
key={t.nr} 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'}`} 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 <input
type="checkbox" type="checkbox"
@@ -298,23 +269,21 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr) if (e.target.checked) neu.add(t.nr); else neu.delete(t.nr)
setGewaehlt(neu) setGewaehlt(neu)
}} }}
className="w-4 h-4 accent-indigo-600 flex-shrink-0" className="w-4 h-4 accent-amber-500 rounded"
/> />
<span className="font-mono text-xs w-14 flex-shrink-0">Titel {t.nr}</span> <span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
<span className="flex-shrink-0">{dauerText(t.dauer_s)}</span> <span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
{t.kapitel > 0 && ( {t.kapitel > 0 && (
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{t.kapitel} Kap.</span> <span className="text-xs text-slate-400">{t.kapitel} Kap.</span>
)} )}
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className="ml-auto text-xs font-mono text-slate-400">
{t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''} {t.groesse_bytes > 0 ? `${(t.groesse_bytes / 1024 ** 3).toFixed(1)} GB` : ''}
</span> </span>
</label> </label>
))} ))}
</div> </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'}`}> <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 {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> </p>
</> </>
)} )}
@@ -322,80 +291,57 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
)} )}
{/* Ordner-Browser */} {/* Ordner-Browser */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> <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 ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-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 <button
onClick={() => browseParent && laden(browseParent)} onClick={() => browseParent && laden(browseParent)}
disabled={!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'}`} 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" title="Ordner hoch"
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span> <span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<button <Button
variant={customPath === browsePath ? 'amber' : 'secondary'}
size="sm"
onClick={() => setCustomPath(browsePath)} 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'} {customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</button> </Button>
</div> </div>
<div className="max-h-40 overflow-y-auto"> <div className="max-h-36 overflow-y-auto p-1">
{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 => ( {browseDirs.map(d => (
<button <button
key={d.path} key={d.path}
onClick={() => laden(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'}`} 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={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} /> <Folder size={14} className="text-slate-400" />
{d.name} {d.name}
</button> </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>
</div> </div>
{/* Gewähltes Ziel */} {/* Gewähltes Ziel Display */}
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <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">
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />} {customPath ? <FolderOpen size={16} className="text-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> <span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
{customPath || targets.find(t => t.type === selectedType)?.path} {customPath || targets.find(t => t.type === selectedType)?.path}
</span> </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>
) )
} }
+113 -95
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,35 +149,37 @@ 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>
<CardContent>
{targets.length === 0 ? ( {targets.length === 0 ? (
<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> <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"> <div className="space-y-2.5">
{targets.map(t => ( {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 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">
<div className="flex items-center gap-3"> <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'} /> <HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
<div> <div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{t.name}</span> <span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<span className={`text-xs ml-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''} {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span> </span>
</div> </div>
</div> </div>
{mounts.some(m => m.name === t.name) && ( {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"> <button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)} )}
@@ -187,82 +187,79 @@ export default function StorageMounts() {
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht {/* Ordner-Verwaltung */}
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet <Card>
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 <button
onClick={() => setBrowserOffen(!browserOffen)} 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'}`} 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> <div>
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ordner-Verwaltung (optional) Ordner-Verwaltung (optional)
</h3> </h3>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <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 Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder „doku".
„doku", die danach im Rippen starten"-Dialog wählbar sind. Fürs normale
Rippen brauchst du das hier nicht.
</p> </p>
</div> </div>
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}> <span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'} {browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span> </span>
</button> </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'}`}> {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 <button
onClick={() => browseParent && browsen(browseParent)} onClick={() => browseParent && browsen(browseParent)}
disabled={!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'}`} 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" title="Ordner hoch"
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span> <span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<input <input
value={neuerOrdner} value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)} onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner" 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'}`} 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 <button
onClick={ordnerAnlegen} onClick={ordnerAnlegen}
disabled={!neuerOrdner} disabled={!neuerOrdner}
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`} className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Ordner anlegen" title="Ordner anlegen"
> >
<FolderPlus size={16} /> <FolderPlus size={16} />
</button> </button>
</div> </div>
<div className="max-h-36 overflow-y-auto"> <div className="max-h-44 overflow-y-auto p-2">
{browseDirs.length === 0 && browseFiles.length === 0 ? ( {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> <p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
) : ( ) : (
<> <>
{browseDirs.map(d => ( {browseDirs.map(d => (
<button <button
key={d.path} key={d.path}
onClick={() => browsen(d.path)} onClick={() => browsen(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'}`} 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={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} /> <Folder size={15} className="text-slate-400 dark:text-slate-500" />
{d.name} {d.name}
</button> </button>
))} ))}
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
{browseFiles.map(f => ( {browseFiles.map(f => (
<div <div
key={f.name} key={f.name}
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`} 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" /> <File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span> <span className="truncate">{f.name}</span>
{f.size_mb != null && ( {f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0"> <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`} {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span> </span>
)} )}
@@ -271,91 +268,112 @@ export default function StorageMounts() {
</> </>
)} )}
</div> </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"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Quelle (host:/export/pfad) label="Quelle (host:/export/pfad)"
</label> value={source}
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" /> onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</div> </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="Rechner (dein PC oder NAS — Name oder IP)"
</label> value={host}
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" /> onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC"
/>
</div> </div>
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.): <Input
Windows/NAS lehnen Gast-Abfragen fast immer mit label="Benutzer"
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der value={username}
Knopf darunter scheinbar grundlos. */} onChange={e => setUsername(e.target.value)}
<div> autoComplete="off"
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label> placeholder="Freigabe-Benutzer"
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" /> />
</div> <Input
<div> label="Passwort"
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label> type="password"
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" /> value={password}
</div> onChange={e => setPassword(e.target.value)}
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> autoComplete="new-password"
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben />
versucht Rippy einen Gast-Zugriff, der meist mit Zugriff verweigert" endet. <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> </p>
<div className="md:col-span-2"> <div className="md:col-span-2">
<button <Button
variant="secondary"
size="sm"
onClick={freigabenAuflisten} onClick={freigabenAuflisten}
disabled={!host || sharesBusy} 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'} {sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</button> </Button>
</div> </div>
{shares.length > 0 && ( {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> <Select
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}> label="Freigabe wählen"
value={selectedShare}
onChange={e => setSelectedShare(e.target.value)}
>
{shares.map(s => <option key={s} value={s}>{s}</option>)} {shares.map(s => <option key={s} value={s}>{s}</option>)}
</select> </Select>
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
<button
<Button
variant="amber"
onClick={hinzufuegen} onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))} 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'} {busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</button> </Button>
{feedback && ( {feedback && (
<p className={`mt-3 text-sm flex items-center gap-2 ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}> <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.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback} {feedback}
</p> </p>
)} )}
</div> </CardContent>
</Card>
</div> </div>
) )
} }
+57 -61
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,28 +84,32 @@ 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>
<CardContent>
{workers.length === 0 ? ( {workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2.5">
{workers.map(w => ( {workers.map(w => (
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> <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">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<span <span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`} 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'} title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
/> />
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} /> <Server size={18} className="text-slate-400 dark:text-slate-500" />
<div className="min-w-0"> <div className="min-w-0">
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p> <p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)} {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{w.info?.ip ? ` · IP ${w.info.ip}` : ''} {w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''} {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
@@ -123,7 +119,11 @@ export default function WorkerVerwaltung() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end"> <div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => ( {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'}`}> <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} {ENCODER_LABELS[e] || e}
</span> </span>
))} ))}
@@ -135,8 +135,8 @@ export default function WorkerVerwaltung() {
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() }) .then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen')) .catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}} }}
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an" title="Verwaisten Eintrag entfernen"
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10" className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@@ -146,69 +146,65 @@ export default function WorkerVerwaltung() {
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
{/* Neue Maschine anbinden — zwei Varianten */} {/* Neue Maschine anbinden */}
<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-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3> <CardHeader>
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
{/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/ </CardHeader>
Postgres — das geht nur im Heimnetz, nie über die externe Domain */} <CardContent className="space-y-4">
<div className="mb-3"> <div className="max-w-md">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name) label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
</label>
<input
value={rippyHost} value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())} 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" placeholder="192.168.178.162"
/> />
{siehtOeffentlichAus && ( {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'}`}> <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">
Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich 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 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.
Proxy blockt den Installer-Download (401). Hier die <strong>LAN-IP</strong> der
Rippy-Maschine eintragen, z. B. 192.168.178.162.
</p> </p>
)} )}
</div> </div>
<div className="flex gap-2 mb-3"> <div className="flex gap-2">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => ( {([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<button <Button
key={wert} key={wert}
variant={variante === wert ? 'amber' : 'secondary'}
size="sm"
onClick={() => setVariante(wert)} onClick={() => setVariante(wert)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
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} {label}
</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' {variante === 'linux'
? '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.' ? '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 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.'} : '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> </p>
<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'}`}>
<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">
{installBefehl} {installBefehl}
<button <button
onClick={kopieren} onClick={kopieren}
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600" 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"
title="Kopieren" title="Kopieren"
> >
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />} {kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
</button> </button>
</div> </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, <p className="text-xs text-slate-500 dark:text-slate-400">
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz. Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, wo das Laufwerk hängt.
</p> </p>
</div> </CardContent>
</Card>
</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>
+171 -233
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> />
{/* Cinematic Spotlight Card für aktiven Rip/Transcode */}
{aktiverJob && (
<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">
<div className="flex flex-col md:flex-row items-center gap-6">
<div className="relative flex-shrink-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">
<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" />
</div>
</div>
<span className="absolute -top-1 -right-1 flex h-4 w-4">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-amber-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-4 w-4 bg-amber-500"></span>
</span>
</div> </div>
{/* System-Leiste: Encoder + aktiver Job auf einen Blick */} <div className="flex-1 min-w-0 text-center md:text-left space-y-2">
<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'}`}> <div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
<div className="flex items-center gap-2"> <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">
<Activity size={16} className="text-indigo-500" /> {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span> </span>
<TypeBadge type={aktiverJob.type} />
</div>
<h2 className="text-2xl font-bold text-slate-100 truncate">
{aktiverJob.title || 'Aktiver Job'}
</h2>
<p className="text-xs text-slate-400">
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
</p>
<div className="pt-2">
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
</div>
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
<div
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
style={{ width: `${aktiverJob.progress}%` }}
/>
</div>
</div>
</div>
<div className="flex flex-col gap-2 flex-shrink-0">
<Button variant="secondary" size="sm" onClick={() => setDetailJobId(aktiverJob.id)}>
Details
</Button>
<Button
variant="danger"
size="sm"
onClick={() => api.post(`/jobs/${aktiverJob.id}/cancel`).catch(() => {})}
>
Abbrechen
</Button>
</div>
</div>
</Card>
)}
{/* System-Telemetrie Leiste */}
<Card>
<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 ? ( {workers.length === 0 ? (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Worker</span> <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 => ( workers.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).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-100 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> <span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
{ENCODER_KURZ[e] || e} 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> </span>
)) ))
)} )}
</div> </div>
<div className="flex items-center gap-2">
<Clock size={16} className="text-indigo-500" /> <div className="flex items-center gap-2.5">
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span> <Clock size={17} className="text-amber-500" />
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
{aktiverJob ? ( {aktiverJob ? (
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200 font-medium">
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} % {aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} · {aktiverJob.progress} %
</span> </span>
) : ( ) : (
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>kein Job</span> <span className="text-slate-400 dark:text-slate-500 italic">kein Job</span>
)} )}
</div> </div>
{plaetze.length > 0 && ( {plaetze.length > 0 && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2.5 ml-auto">
<HardDrive size={16} className="text-indigo-500" /> <HardDrive size={17} className="text-amber-500" />
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
{plaetze.map(p => ( {plaetze.map(p => (
<span <span
key={p.name} key={p.name}
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`} title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
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'}`} 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 {p.name.startsWith('Media') ? 'Media' : 'Arbeit'} {p.frei_gb} GB
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
{/* Laufwerke + erkannte Disc */} {/* 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> </div>
</CardContent>
</Card>
) )
} }
+85 -118
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>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500"> <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>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500"> <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>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500"> <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>
{['all', 'info', 'success', 'warning', 'error'].map(f => (
<button <button
onClick={() => setFilter('all')} key={f}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ onClick={() => setFilter(f)}
filter === 'all' className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
? 'bg-indigo-600 text-white' filter === f
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' ? '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 {f === 'all' ? 'Alle' : getLevelLabel(f)}
</button> </button>
<button ))}
onClick={() => setFilter('info')}
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>
) )
} }
+251 -414
View File
@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react' import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } 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 StorageMounts from '../components/StorageMounts' import StorageMounts from '../components/StorageMounts'
import WorkerVerwaltung from '../components/WorkerVerwaltung' import WorkerVerwaltung from '../components/WorkerVerwaltung'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
import { Input, Select, Toggle } from '../components/ui/Input'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { ENCODER_BADGES } from '../lib/design'
interface SettingsState { interface SettingsState {
tmdbApiKey: string tmdbApiKey: string
@@ -68,15 +73,6 @@ interface SystemInfo {
webhook_gesetzt: boolean webhook_gesetzt: boolean
} }
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
const ENCODER_LABELS: Record<string, string> = {
'cpu-x264': 'CPU · H.264',
'cpu-x265': 'CPU · H.265',
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
'nvenc': 'Hardware · NVENC (NVIDIA)',
}
export default function SettingsPage() { export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings) const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping') const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
@@ -91,6 +87,8 @@ export default function SettingsPage() {
const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null) const [updates, setUpdates] = useState<Record<string, { installiert?: string, verfuegbar?: string, update?: boolean }> | null>(null)
const [updatesBusy, setUpdatesBusy] = useState(false) const [updatesBusy, setUpdatesBusy] = useState(false)
const { toast } = useToast()
const updatesPruefen = async () => { const updatesPruefen = async () => {
setUpdatesBusy(true) setUpdatesBusy(true)
try { try {
@@ -107,8 +105,6 @@ export default function SettingsPage() {
setUpdatesBusy(false) setUpdatesBusy(false)
} }
} }
const { theme } = useDarkMode()
const { toast } = useToast()
const quellenPruefen = async () => { const quellenPruefen = async () => {
setQuellenBusy(true) setQuellenBusy(true)
@@ -154,7 +150,6 @@ export default function SettingsPage() {
const loadSettings = async () => { const loadSettings = async () => {
try { try {
const response = await api.get('/settings') const response = await api.get('/settings')
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
setSettings({ ...defaultSettings, ...response.data }) setSettings({ ...defaultSettings, ...response.data })
} catch { } catch {
setSettings(defaultSettings) setSettings(defaultSettings)
@@ -187,199 +182,177 @@ export default function SettingsPage() {
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>
) )
} }
const tabs: { id: SettingsTab; label: string; icon: any }[] = [
{ id: 'ripping', label: 'Ripping', icon: Disc },
{ id: 'verarbeitung', label: 'Verarbeitung', icon: Cpu },
{ id: 'worker', label: 'Worker', icon: Cpu },
{ id: 'speicherziele', label: 'Speicherziele', icon: HardDrive },
{ id: 'apis', label: 'APIs', icon: Globe },
{ id: 'benachrichtigungen', label: 'Benachrichtigungen', icon: AlertCircle },
{ id: 'system', label: 'System', icon: Wrench },
]
return ( return (
<div className={`max-w-4xl space-y-6 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="max-w-4xl space-y-6">
<div className="flex flex-col gap-2 transition-colors duration-300"> <PageHeader
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Einstellungen</h1> title="Einstellungen"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p> subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
</div> />
{saved && ( {saved && (
<div className={`p-4 rounded-lg flex items-center gap-3 font-medium transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 border border-emerald-700 text-emerald-400' : 'bg-emerald-50 border border-emerald-200 text-emerald-700'}`}> <div className="p-4 rounded-xl flex items-center gap-3 font-medium bg-emerald-500/15 border border-emerald-500/30 text-emerald-600 dark:text-emerald-400">
<CheckCircle className="flex-shrink-0" size={24} /> <CheckCircle className="flex-shrink-0" size={22} />
<span>Einstellungen erfolgreich gespeichert!</span> <span>Einstellungen erfolgreich gespeichert!</span>
</div> </div>
)} )}
<div className={`rounded-xl shadow-sm overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border border-slate-700' : 'bg-white border border-slate-200'}`}> <Card className="overflow-hidden">
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */} {/* Navigation Tabs */}
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}> <div className="flex border-b border-slate-200/80 dark:border-slate-800 overflow-x-auto bg-slate-50/50 dark:bg-slate-950/50">
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} /> {tabs.map((tab) => {
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} /> const isActive = activeTab === tab.id
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} /> return (
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} /> <button
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} /> key={tab.id}
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} /> onClick={() => setActiveTab(tab.id)}
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} /> className={`px-5 py-3.5 text-sm font-medium transition-all flex items-center gap-2 border-b-2 whitespace-nowrap ${
isActive
? 'border-amber-500 text-amber-600 dark:text-amber-400 bg-white dark:bg-slate-900 font-semibold'
: 'border-transparent text-slate-500 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
>
<tab.icon size={17} className={isActive ? 'text-amber-500' : ''} />
<span>{tab.label}</span>
</button>
)
})}
</div> </div>
{/* Content */} {/* Tab Content */}
<div className="p-6 space-y-8 transition-colors duration-300"> <CardContent className="p-6 space-y-8">
{/* Ripping Settings */} {/* Ripping Settings */}
{activeTab === 'ripping' && <section> {activeTab === 'ripping' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <div>
<h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Disc size={20} className="text-amber-500" />
Ripping-Einstellungen Ripping-Einstellungen
</h2> </h2>
</div>
<div className="space-y-6 transition-colors duration-300"> <div className="space-y-4">
{/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */} <p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
<p className={`text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
📀 Video DVD · Blu-ray · 4K UHD 📀 Video DVD · Blu-ray · 4K UHD
</p> </p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-indigo-900/30 text-indigo-400' : 'bg-indigo-100 text-indigo-600'}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Vollautomatik: sofort rippen</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Disc erkannt Rip startet ohne Nachfrage in den passenden Schnellwahl-Ordner
(Serie Serien, sonst Filme; CD Musik). Aus = wie bisher per Rippen starten"-Dialog.
</p>
</div>
</div>
<Toggle <Toggle
label="Vollautomatik: sofort rippen"
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
checked={settings.autoRipStart} checked={settings.autoRipStart}
onChange={(v) => handleChange('autoRipStart', v)} onChange={(v) => handleChange('autoRipStart', v)}
darkMode={theme === 'dark'}
/> />
</div> </div>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-purple-900/30 text-purple-400' : 'bg-purple-100 text-purple-600'}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nur Hauptfeature</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
</div>
</div>
<Toggle <Toggle
label="Nur Hauptfeature"
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
checked={settings.mainFeatureOnly} checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)} onChange={(v) => handleChange('mainFeatureOnly', v)}
darkMode={theme === 'dark'}
/> />
</div> </div>
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-slate-500 dark:text-slate-400 px-1">
🌐 Sprachen &amp; Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und 🌐 Sprachen &amp; Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
Untertitel verlustfrei, und auch die Kompression behält alle (--all-audio /
--all-subtitles) — gerade bei Animes gehen also weder O-Ton noch Subs verloren.
</p> </p>
{/* ── Audio-CD ──────────────────────────────────────────── */} <p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
🎵 Audio-CD 🎵 Audio-CD
</p> </p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-600'}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Alle Tracks rippen</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)</p>
</div>
</div>
<Toggle <Toggle
label="Alle Tracks rippen"
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
checked={settings.ripAllTracks} checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)} onChange={(v) => handleChange('ripAllTracks', v)}
darkMode={theme === 'dark'}
/> />
</div> </div>
{/* ── Allgemein ─────────────────────────────────────────── */} <p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Allgemein Allgemein
</p> </p>
<div className={`flex items-center justify-between p-4 transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-700/50 rounded-lg' : 'bg-slate-50 rounded-lg'}`}> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg transition-colors duration-300 ${theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-600'}`}>
<Disc size={20} />
</div>
<div>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Automatischer Auswurf</h3>
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Disk nach erfolgreichem Ripping auswerfen</p>
</div>
</div>
<Toggle <Toggle
label="Automatischer Auswurf"
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
checked={settings.autoEject} checked={settings.autoEject}
onChange={(v) => handleChange('autoEject', v)} onChange={(v) => handleChange('autoEject', v)}
darkMode={theme === 'dark'}
/> />
</div> </div>
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */} {/* Media-Server Integration */}
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-4">
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <Tv size={18} className="text-amber-500" />
Media-Server (Jellyfin &amp; Co.) Media-Server (Jellyfin &amp; Co.)
</h3> </h3>
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als „Titel (Jahr)" und legt je nach System Rippy benennt fertige Rips als Titel (Jahr)" und legt NFO + Poster dazu.
movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.
</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 => (
<button <button
key={o.wert} key={o.wert}
type="button"
onClick={() => handleChange('mediaServer', o.wert)} onClick={() => handleChange('mediaServer', 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 ${
settings.mediaServer === o.wert settings.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-white dark:bg-slate-900 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-600 text-slate-400 hover:border-slate-500'
: 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
}`} }`}
> >
{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 text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis} {MEDIA_SERVER_OPTIONEN.find(o => o.wert === settings.mediaServer)?.hinweis}
</p> </p>
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
{(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && ( {(settings.mediaServer === 'jellyfin' || settings.mediaServer === 'emby') && (
<div className={`mt-4 pt-4 border-t space-y-3 ${theme === 'dark' ? 'border-slate-600' : 'border-slate-200'}`}> <div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> <p className="text-xs text-slate-600 dark:text-slate-300">
<strong>Bibliotheks-Refresh</strong> (optional): Trag Server-URL + API-Key ein, <strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
dann stößt Rippy nach jedem fertigen Rip sofort einen Scan an der Film
erscheint ohne Warten in {settings.mediaServer === 'jellyfin' ? 'Jellyfin' : 'Emby'}.
API-Key: Administration API-Schlüssel Neu.
</p> </p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
<input <Input
type="text" label="Server-URL"
value={settings.jellyfinUrl} value={settings.jellyfinUrl}
onChange={(e) => handleChange('jellyfinUrl', e.target.value)} onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
placeholder="http://192.168.178.50:8096" placeholder="http://192.168.178.50:8096"
className={`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 className="flex gap-2"> <div className="flex gap-2 items-end">
<input <div className="flex-1">
<Input
type="password" type="password"
label="API-Key"
value={settings.jellyfinApiKey} value={settings.jellyfinApiKey}
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)} onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
placeholder="API-Key" placeholder="API-Key"
className={`flex-1 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'}`}
/> />
<button </div>
<Button
variant="amber"
size="md"
onClick={async () => { onClick={async () => {
try { try {
await api.post('/mediaserver/refresh', { await api.post('/mediaserver/refresh', {
@@ -391,41 +364,44 @@ export default function SettingsPage() {
} }
}} }}
disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey} disabled={!settings.jellyfinUrl || !settings.jellyfinApiKey}
className="px-3 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
> >
Testen Testen
</button> </Button>
</div> </div>
</div> </div>
</div> </div>
)} )}
</div> </div>
</div> </div>
</section>} </section>
)}
{/* Verarbeitung: Kompressions-Stufe nach dem Rip */} {/* Verarbeitung */}
{activeTab === 'verarbeitung' && <section> {activeTab === 'verarbeitung' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Verarbeitung (Kompression) Verarbeitung (Kompression)
</h2> </h2>
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.
</p> </p>
{/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */} <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Verfügbare Encoder</p>
{workers.length === 0 ? ( {workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Kein Worker gemeldet.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Kein Worker gemeldet.</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 mr-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}</span> <span className="text-xs font-mono mr-2 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-800 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.5 py-0.5 rounded-md font-medium border mr-1 ${
{ENCODER_LABELS[e] || e} e.startsWith('cpu')
? 'bg-slate-200 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> </span>
))} ))}
</div> </div>
@@ -433,132 +409,115 @@ export default function SettingsPage() {
)} )}
</div> </div>
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-4">
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div> <Toggle
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nach dem Rip komprimieren</h3> label="Nach dem Rip komprimieren"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>x265 statt 40-GB-Rohdatei (empfohlen)</p> description="x265 statt 40-GB-Rohdatei (empfohlen)."
</div> checked={settings.transcodeEnabled}
<Toggle checked={settings.transcodeEnabled} onChange={(v) => handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} /> onChange={(v) => handleChange('transcodeEnabled', v)}
/>
</div> </div>
<div className="space-y-2"> <Select
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> label="HandBrake-Preset"
HandBrake-Preset
</label>
<select
value={settings.transcodePreset} value={settings.transcodePreset}
onChange={(e) => handleChange('transcodePreset', e.target.value)} onChange={(e) => handleChange('transcodePreset', e.target.value)}
className={`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'}`}
> >
<option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (Standard)</option> <option value="H.265 MKV 1080p30">H.265 MKV 1080p30 klein &amp; modern (Standard)</option>
<option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K für UHD-Quellen</option> <option value="H.265 MKV 2160p60 4K">H.265 MKV 2160p60 4K für UHD-Quellen</option>
<option value="HQ 1080p30 Surround">HQ 1080p30 Surround H.264, maximale Kompatibilität</option> <option value="HQ 1080p30 Surround">HQ 1080p30 Surround H.264, maximale Kompatibilität</option>
<option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround größer, beste Qualität</option> <option value="Super HQ 1080p30 Surround">Super HQ 1080p30 Surround größer, beste Qualität</option>
</select> </Select>
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<Toggle
label="Original behalten"
description="Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)."
checked={settings.keepOriginal}
onChange={(v) => handleChange('keepOriginal', v)}
/>
</div> </div>
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <Input
<div> label="Arbeitsverzeichnis für Roh-Rips (optional)"
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Original behalten</h3>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Verlustfreie Roh-Datei zusätzlich aufheben (viel Speicher!)</p>
</div>
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
</div>
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
<div className="space-y-2">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Arbeitsverzeichnis für Roh-Rips (optional)
</label>
<input
type="text"
value={settings.workDir} value={settings.workDir}
onChange={(e) => handleChange('workDir', e.target.value)} onChange={(e) => handleChange('workDir', e.target.value)}
placeholder="leer = /app/temp (Container-Platte)" placeholder="leer = /app/temp (Container-Platte)"
className={`w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent font-mono text-sm ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
/> />
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
Muss unter /app/media liegen (z. B. /app/media/nas-arbeit Freigabe vorher unter Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
Speicherziele einhängen). Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß;
Rippy prüft den Platz VOR dem Rip und bricht sonst mit Klartext ab.
{systemInfo?.plaetze?.length ? ( {systemInfo?.plaetze?.length ? (
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</> <> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
) : null} ) : null}
</p> </p>
</div> </div>
</div> </section>
</section>} )}
{/* Worker: Encoding-Maschinen im Netz */} {/* Worker */}
{activeTab === 'worker' && <section> {activeTab === 'worker' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Cpu size={20} className="text-amber-500" />
Encoding-Worker Encoding-Worker
</h2> </h2>
<WorkerVerwaltung /> <WorkerVerwaltung />
</section>} </section>
)}
{/* Speicherziele: Netzwerk-Mounts aus dem UI */} {/* Speicherziele */}
{activeTab === 'speicherziele' && <section> {activeTab === 'speicherziele' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<HardDrive size={20} className="text-amber-500" />
Speicherziele Speicherziele
</h2> </h2>
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */} <div className="p-5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
<div className={`rounded-lg p-4 mb-6 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}> <h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
<Database size={16} className="text-indigo-500" /> Schnellwahl beim Rippen
</h3> </h3>
<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">
Beim Start eines Rips fragt Rippy Wohin?" — diese drei Ordner erscheinen Name der Ordner im Rippen starten"-Dialog (🎥/📺/🎵).
dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen.
</p> </p>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => ( {([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
<div key={feld}> <Input
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label> key={feld}
<input label={label}
type="text"
value={settings[feld]} value={settings[feld]}
onChange={(e) => handleChange(feld, e.target.value)} onChange={(e) => handleChange(feld, e.target.value)}
className={`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'}`}
/> />
</div>
))} ))}
</div> </div>
</div> </div>
<StorageMounts /> <StorageMounts />
</section>} </section>
)}
{/* API Keys */} {/* APIs */}
{activeTab === 'apis' && <section> {activeTab === 'apis' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Globe size={20} className="text-amber-500" />
API-Schlüssel API-Schlüssel
</h2> </h2>
{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */} <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 flex flex-wrap items-center gap-3">
<div className={`rounded-lg p-4 mb-4 flex flex-wrap items-center gap-3 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
<button
onClick={quellenPruefen}
disabled={quellenBusy}
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"
>
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'} {quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
</button> </Button>
{quellenStatus ? ( {quellenStatus ? (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{Object.entries(quellenStatus).map(([quelle, status]) => ( {Object.entries(quellenStatus).map(([quelle, status]) => (
<span <span
key={quelle} key={quelle}
className={`text-xs px-2.5 py-1 rounded-full font-medium ${ className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
status === 'ok' ? 'bg-emerald-600/20 text-emerald-500' status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: status === 'kein_key' ? (theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-200 text-slate-500') : status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
: 'bg-rose-600/20 text-rose-500' : 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
}`} }`}
> >
{quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'} {quelle.toUpperCase()}: {status === 'ok' ? '✓ funktioniert' : status === 'kein_key' ? 'kein Key' : '✗ Fehler'}
@@ -566,295 +525,173 @@ export default function SettingsPage() {
))} ))}
</div> </div>
) : ( ) : (
<span className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <span className="text-xs text-slate-500 dark:text-slate-400">
Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken. Prüft live gegen TMDB/OMDb — nach dem Speichern eines neuen Keys hier klicken.
Beide TMDB-Key-Arten funktionieren (v3-Schlüssel und v4-Token).
</span> </span>
)} )}
</div> </div>
<div className="space-y-4 transition-colors duration-300"> <div className="space-y-4">
<div className="space-y-2 transition-colors duration-300"> <Input
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
TMDB API Key
</label>
<input
type="password" type="password"
label="TMDB API Key"
value={settings.tmdbApiKey} value={settings.tmdbApiKey}
onChange={(e) => handleChange('tmdbApiKey', e.target.value)} onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="Dein TMDB API Key" placeholder="Dein TMDB API Key"
/> />
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Hol dir einen Key auf <a href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>themoviedb.org</a>
</p>
</div>
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
TVDb API Key
</label>
<input
type="password" type="password"
label="TVDb API Key"
value={settings.tvdbApiKey} value={settings.tvdbApiKey}
onChange={(e) => handleChange('tvdbApiKey', e.target.value)} onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="Dein TVDb API Key" placeholder="Dein TVDb API Key"
/> />
</div> <Input
<div className="space-y-2 transition-colors duration-300">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
OMDb API Key (Fallback)
</label>
<input
type="password" type="password"
label="OMDb API Key (Fallback)"
value={settings.omdbApiKey} value={settings.omdbApiKey}
onChange={(e) => handleChange('omdbApiKey', e.target.value)} onChange={(e) => handleChange('omdbApiKey', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="Dein OMDb API Key" placeholder="Dein OMDb API Key"
/> />
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Zweite Quelle, wenn TMDB nichts findet kostenloser Key auf <a href="https://www.omdbapi.com/apikey.aspx" target="_blank" rel="noopener noreferrer" className={`${theme === 'dark' ? 'text-indigo-400 hover:underline' : 'text-indigo-600 hover:underline'}`}>omdbapi.com</a>
</p>
</div> </div>
</div> </section>
</section>} )}
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */} {/* Benachrichtigungen */}
{activeTab === 'benachrichtigungen' && <section> {activeTab === 'benachrichtigungen' && (
<h2 className={`text-lg font-semibold mb-2 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<AlertCircle size={20} className="text-amber-500" />
Webhook-Benachrichtigungen Webhook-Benachrichtigungen
</h2> </h2>
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder <strong>abgebrochen</strong> wird.
<strong> abgebrochen</strong> wird direkt in deinen Chat. Der Dienst wird an der URL
automatisch erkannt, du musst nichts weiter einstellen.
</p> </p>
{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */} <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3 text-sm">
<div className={`rounded-lg p-4 mb-4 space-y-3 text-sm ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
<div> <div>
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>💬 Discord (einfachster Weg)</p> <p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}> <p className="text-xs text-slate-500 dark:text-slate-400">
Server Kanal-Zahnrad Integrationen Webhooks Neuer Webhook" → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" URL kopieren.
„Webhook-URL kopieren" und unten einfügen.
</p> </p>
</div> </div>
<div> <div>
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>📱 ntfy (Push aufs Handy, ohne Account)</p> <p className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}> <p className="text-xs text-slate-500 dark:text-slate-400">
ntfy-App installieren, ein Thema abonnieren (z. B. rippy-tobi-4711") und unten Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
https://ntfy.sh/rippy-tobi-4711 eintragen. Thema = Passwort, also etwas Einmaliges wählen.
</p>
</div>
<div>
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>🔧 Slack / Eigenes</p>
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
Slack: „Incoming Webhook" in den App-Einstellungen anlegen. Alles andere
(Home Assistant, n8n, eigene Skripte) bekommt JSON: {'{'}"title", "message", "level"{'}'}.
</p> </p>
</div> </div>
</div> </div>
<div className="space-y-2 transition-colors duration-300"> <div className="space-y-2">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <div className="flex gap-2 items-end">
Webhook URL <div className="flex-1">
</label> <Input
<div className="flex gap-2"> label="Webhook URL"
<input
type="text"
value={settings.notificationWebhook} value={settings.notificationWebhook}
onChange={(e) => handleChange('notificationWebhook', e.target.value)} onChange={(e) => handleChange('notificationWebhook', e.target.value)}
className={`flex-1 px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema" placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
/> />
<button </div>
<Button
variant="amber"
size="md"
onClick={webhookTesten} onClick={webhookTesten}
disabled={!settings.notificationWebhook || testBusy} disabled={!settings.notificationWebhook || testBusy}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
> >
<Send size={15} /> <Send size={15} />
{testBusy ? 'Sende…' : 'Test senden'} {testBusy ? 'Sende…' : 'Test senden'}
</button> </Button>
</div> </div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Test senden" prüft die Anbindung sofort — nicht vergessen, danach zu speichern,
damit auch der Worker die URL kennt.
</p>
</div> </div>
</section>} </section>
)}
{/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */} {/* System */}
{activeTab === 'system' && <section> {activeTab === 'system' && (
<h2 className={`text-lg font-semibold mb-4 flex items-center gap-2 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <section className="space-y-6">
<Wrench size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <h2 className="text-lg font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
<Wrench size={20} className="text-amber-500" />
System &amp; Kern-Werkzeuge System &amp; Kern-Werkzeuge
</h2> </h2>
{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */} <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
<div className={`rounded-lg p-4 mb-4 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Installierte Werkzeuge</p>
{!systemInfo || systemInfo.workers.length === 0 ? ( {!systemInfo || systemInfo.workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : ( ) : (
systemInfo.workers.map(w => ( systemInfo.workers.map(w => (
<div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5"> <div key={w.name} className="flex flex-wrap items-center gap-2 mb-1.5">
<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.info?.makemkv && ( {w.info?.makemkv && (
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}> <span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
MakeMKV {w.info.makemkv} MakeMKV {w.info.makemkv}
</span> </span>
)} )}
{w.info?.handbrake && ( {w.info?.handbrake && (
<span className={`text-xs px-2 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-800 text-slate-300' : 'bg-slate-200 text-slate-600'}`}> <span className="text-xs px-2.5 py-0.5 rounded-md font-medium bg-slate-200 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-700">
HandBrake {w.info.handbrake} HandBrake {w.info.handbrake}
</span> </span>
)} )}
{w.info?.makemkv_key && (
<span className={`text-xs px-2 py-0.5 rounded ${w.info.makemkv_key === 'keiner' ? 'bg-amber-600/20 text-amber-500' : 'bg-emerald-600/20 text-emerald-500'}`}>
{w.info.makemkv_key === 'ui' ? 'Beta-Key: aus dem UI' :
w.info.makemkv_key === 'env' ? 'Beta-Key: aus .env' :
'Kein Beta-Key (Blu-ray nur im Testmodus)'}
</span>
)}
</div> </div>
)) ))
)} )}
{systemInfo?.plaetze && systemInfo.plaetze.length > 0 && (
<p className={`text-xs mt-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Speicherplatz: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`).join(' · ')}
</p>
)}
</div> </div>
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */} <Input
<div className="space-y-2">
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
MakeMKV Beta-Key
</label>
<input
type="password" type="password"
label="MakeMKV Beta-Key"
value={settings.makemkvAppKey} value={settings.makemkvAppKey}
onChange={(e) => handleChange('makemkvAppKey', e.target.value)} onChange={(e) => handleChange('makemkvAppKey', e.target.value)}
className={`w-full px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all font-mono text-sm`} placeholder="T-… (Forum-Thread „MakeMKV is free while in beta”)"
placeholder={'T-… (Forum-Thread „MakeMKV is free while in beta")'}
/> />
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Der Key wechselt etwa monatlich (MakeMKV-Forum, Thread t=1053). Hier eintragen +
speichern — er gilt ab dem <strong>nächsten Rip</strong>, ganz ohne Neustart oder Rebuild
und schlägt den Key aus der .env. DVDs gehen immer auch ohne Key.
</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Die MakeMKV-<strong>Version</strong> selbst steckt im Worker-Image — Update per
Rebuild: <code>docker compose build worker &amp;&amp; docker compose up -d worker</code> (siehe README).
</p>
</div>
{/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten <div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80 space-y-3">
kann es nicht (die Versionen stecken im Worker-Image) */} <div className="flex items-center justify-between">
<div className={`rounded-lg p-4 mt-6 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
<div className="flex items-center justify-between mb-2"> <Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Update-Check</p>
<button
onClick={updatesPruefen}
disabled={updatesBusy}
className="px-3 py-1.5 rounded-lg bg-indigo-600 text-white text-xs font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
>
{updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'} {updatesBusy ? 'Prüfe…' : 'Auf Updates prüfen'}
</button> </Button>
</div>
{updates && (
<div className="space-y-2 text-sm text-slate-700 dark:text-slate-300">
<div>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || '—'}, aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
</div>
<div>
<strong>HandBrake:</strong> installiert {updates.handbrake?.installiert || '—'}, verfügbar {updates.handbrake?.verfuegbar || 'unbekannt'}
</div>
</div> </div>
{updates ? (
<div className="space-y-2 text-sm">
<div className={updates.makemkv?.update ? 'text-amber-500' : (theme === 'dark' ? 'text-slate-300' : 'text-slate-600')}>
<strong>MakeMKV:</strong> installiert {updates.makemkv?.installiert || ''},
aktuell {updates.makemkv?.verfuegbar || 'unbekannt'}
{updates.makemkv?.update && (
<span className="block text-xs mt-1">
Update: in der .env <code>MAKEMKV_VERSION={updates.makemkv.verfuegbar}</code> setzen,
dann auf der Rippy-Maschine <code>docker compose build worker &amp;&amp; docker compose up -d worker</code>.
Neue Version bringt auch die neueste Disc-Schlüssel-Datenbank mit.
</span>
)} )}
</div> </div>
<div className={theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}> </section>
<strong>HandBrake:</strong> Docker-Worker {updates.handbrake?.installiert || ''} (Debian-Paket),
neueste offizielle Version {updates.handbrake?.verfuegbar || 'unbekannt'}
<span className={`block text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Im Docker-Worker kommt HandBrake aus Debian und hinkt der offiziellen Version
bewusst hinterher (stabil, keine Eigenbauten). Der native Windows-Worker nutzt
die aktuelle Version direkt.
</span>
</div>
</div>
) : (
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Prüft gegen makemkv.com und die HandBrake-GitHub-Releases (Ergebnis wird 12 h gemerkt).
</p>
)} )}
</div>
</section>}
{/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu {/* Global Save Button */}
speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */}
{activeTab !== 'worker' && ( {activeTab !== 'worker' && (
<div className={`flex items-center justify-end gap-4 pt-6 transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-t`}> <div className="flex items-center justify-end gap-4 pt-6 border-t border-slate-200/80 dark:border-slate-800">
<button <Button
variant="amber"
size="lg"
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
> >
{saving ? ( {saving ? (
<> <>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div> <div className="animate-spin rounded-full h-4 w-4 border-2 border-slate-950 border-t-transparent"></div>
Speichern... Speichern...
</> </>
) : ( ) : (
<> <>
<Save size={20} /> <Save size={18} />
Einstellungen speichern Einstellungen speichern
</> </>
)} )}
</button> </Button>
</div> </div>
)} )}
</div> </CardContent>
</div> </Card>
</div> </div>
) )
} }
function TabButton({ icon: Icon, label, isActive, onClick, darkMode }: { icon: any, label: string, isActive: boolean, onClick: () => void, darkMode: boolean }) {
const { theme } = useDarkMode()
return (
<button
onClick={onClick}
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
isActive
? `${darkMode ? 'border-indigo-500 text-indigo-400' : 'border-indigo-600 text-indigo-600'}`
: `${darkMode ? 'border-transparent text-slate-400 hover:text-slate-300' : 'border-transparent text-slate-500 hover:text-slate-700'}`
}`}
>
{label}
</button>
)
}
function Toggle({ checked, onChange, darkMode }: { checked: boolean, onChange: (v: boolean) => void, darkMode: boolean }) {
const { theme } = useDarkMode()
return (
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? 'bg-indigo-600' : darkMode ? 'bg-slate-600' : 'bg-slate-300'
}`}
>
<span
className={`${
checked ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
)
}