refactor(ui): Dashboard, DeviceDiscovery & Settings auf Cinematic Cinema OS umgestellt
Ampel / ampel (push) Successful in 27s
Ampel / ampel (push) Successful in 27s
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
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 MetadataKorrektur from './MetadataKorrektur'
|
||||
|
||||
@@ -35,7 +37,6 @@ interface Device {
|
||||
disc?: DiscInfo
|
||||
}
|
||||
|
||||
// TMDB liefert relative Poster-Pfade, OMDb absolute URLs — beides abdecken
|
||||
function posterUrl(disc?: DiscInfo): string | null {
|
||||
const p = disc?.metadata?.poster_path
|
||||
if (!p) return null
|
||||
@@ -51,12 +52,9 @@ export default function DeviceDiscovery() {
|
||||
const [actionBusy, setActionBusy] = useState(false)
|
||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
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)
|
||||
try {
|
||||
const response = await api.get('/devices')
|
||||
@@ -115,233 +113,194 @@ export default function DeviceDiscovery() {
|
||||
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> = {
|
||||
tmdb: 'TMDB', omdb: 'OMDb', jikan: 'MyAnimeList', manuell: 'manuell gewählt',
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'empty': return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
case 'ready': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700'
|
||||
case 'ripping': return theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700'
|
||||
default: return theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
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} />
|
||||
case 'empty':
|
||||
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 'ready':
|
||||
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:
|
||||
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>
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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'}`}>
|
||||
<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`}>
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<div>
|
||||
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Laufwerke</h2>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>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>
|
||||
<CardTitle>Laufwerke</CardTitle>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||
</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) */}
|
||||
{devices.filter(d => d.status === 'ready' && d.disc).map(device => (
|
||||
<div
|
||||
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) && (
|
||||
<img
|
||||
src={posterUrl(device.disc)!}
|
||||
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">
|
||||
<p className={`text-xs font-semibold uppercase tracking-wider mb-1 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
|
||||
<div className="p-5 flex-1 min-w-0 flex flex-col justify-between">
|
||||
<div>
|
||||
<p className="text-[11px] font-bold uppercase tracking-wider mb-1 text-amber-400">
|
||||
Im Laufwerk erkannt
|
||||
</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!.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>
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${getTypeColor(device.type)}`}>
|
||||
{device.disc!.disc_type?.toUpperCase()}
|
||||
</span>
|
||||
<span className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<TypeBadge type={device.type} />
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Quelle: {QUELLEN_LABEL[device.disc!.metadata?.source || ''] || device.disc!.metadata?.source || 'Disc-Label'}
|
||||
{' · '}{Math.round((device.disc!.confidence || 0) * 100)} % sicher
|
||||
</span>
|
||||
</div>
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
{device.disc!.bereits_gerippt && (
|
||||
<p className={`text-xs mt-2 px-2.5 py-1.5 rounded-lg inline-flex items-center gap-1.5 ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}>
|
||||
<p className="text-xs mt-2 px-3 py-1.5 rounded-xl inline-flex items-center gap-1.5 bg-amber-500/10 border border-amber-500/20 text-amber-600 dark:text-amber-300">
|
||||
<AlertCircle size={13} className="flex-shrink-0" />
|
||||
Diese Disc wurde bereits gerippt
|
||||
{device.disc!.bereits_gerippt.finished_at
|
||||
? ` (${new Date(device.disc!.bereits_gerippt.finished_at).toLocaleDateString('de-DE')})`
|
||||
: ''} — nochmal rippen ist trotzdem möglich.
|
||||
: ''} — nochmal rippen ist möglich.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<button
|
||||
onClick={() => setModalDevice(device)}
|
||||
disabled={actionBusy}
|
||||
className="px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
|
||||
>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<Button variant="amber" onClick={() => setModalDevice(device)} disabled={actionBusy}>
|
||||
Rippen starten
|
||||
</button>
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setKorrekturDevice(device)}>
|
||||
Nicht korrekt?
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{devices.length === 0 ? (
|
||||
<div className="text-center py-12 transition-colors duration-300">
|
||||
<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'}`}>
|
||||
<HardDrive className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} />
|
||||
<div className="text-center py-12">
|
||||
<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 size={32} />
|
||||
</div>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Laufwerke gefunden</p>
|
||||
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">Keine Laufwerke gefunden</p>
|
||||
<p className="text-xs mt-1 text-slate-400 dark:text-slate-500">
|
||||
Stelle sicher, dass ein Laufwerk angeschlossen ist
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
<div className="space-y-3">
|
||||
{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 gap-4">
|
||||
<div className={`p-3 rounded-lg ${getTypeColor(device.type)}`}>
|
||||
<Disc size={24} />
|
||||
<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={22} className={device.status === 'ripping' ? 'animate-disc-spin text-amber-500' : ''} />
|
||||
</div>
|
||||
<div>
|
||||
<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'}
|
||||
</h3>
|
||||
{device.status === 'ripping' && (
|
||||
<span className="flex items-center gap-1 text-xs font-medium text-blue-500">
|
||||
<RefreshCw size={12} className="animate-spin" />
|
||||
Rippt
|
||||
</span>
|
||||
)}
|
||||
<TypeBadge type={device.type} />
|
||||
</div>
|
||||
<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}
|
||||
</span>
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<span className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-md text-xs font-medium ${getStatusColor(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 className="mt-3">
|
||||
{getStatusBadge(device.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setExpandedId(expandedId === device.id ? null : device.id)
|
||||
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'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gerätepfad</span>
|
||||
<span className={`font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.path}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Modell</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>{device.model || 'unbekannt'}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Seriennummer</span>
|
||||
<span className={`font-mono text-xs ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>{device.serial || '—'}</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Disc</span>
|
||||
<span className={theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}>
|
||||
<span className="text-slate-500 dark:text-slate-400">Gerätepfad</span>
|
||||
<span className="font-mono text-slate-700 dark:text-slate-300">{device.path}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Modell</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">{device.model || 'unbekannt'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Seriennummer</span>
|
||||
<span className="font-mono text-xs text-slate-700 dark:text-slate-300">{device.serial || '—'}</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">Disc</span>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{device.status === 'ready' ? `eingelegt (${typLabel(device.type)})` : 'keine'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
<Button
|
||||
variant="amber"
|
||||
size="sm"
|
||||
onClick={() => setModalDevice(device)}
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => ejectDisc(device)}
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
)}
|
||||
@@ -351,10 +310,8 @@ export default function DeviceDiscovery() {
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
{/* Ziel-Auswahl vor dem Rip-Start (Filme/Serien/Musik oder eigener
|
||||
Pfad — dort eingehängte Shares sind direkt wählbar) */}
|
||||
<RipTargetModal
|
||||
isOpen={modalDevice !== null}
|
||||
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
|
||||
devicePath={korrekturDevice?.path || ''}
|
||||
vorschlag={korrekturDevice?.disc?.title}
|
||||
@@ -376,6 +332,6 @@ export default function DeviceDiscovery() {
|
||||
onClose={() => setKorrekturDevice(null)}
|
||||
onUebernommen={refreshDevices}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
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 { useDarkMode } from '../context/ThemeContext'
|
||||
import { Modal } from './ui/Modal'
|
||||
import { Button } from './ui/Button'
|
||||
import { Input } from './ui/Input'
|
||||
|
||||
interface TargetConfig {
|
||||
id: string
|
||||
@@ -15,7 +17,7 @@ export interface RipOptionen {
|
||||
series?: string
|
||||
season?: number
|
||||
mainFeatureOnly?: boolean
|
||||
titles?: number[] // exakte Titel-Auswahl (Track-Tabelle)
|
||||
titles?: number[]
|
||||
}
|
||||
|
||||
interface TitelInfo {
|
||||
@@ -27,9 +29,9 @@ interface TitelInfo {
|
||||
|
||||
interface RipTargetModalProps {
|
||||
isOpen: boolean
|
||||
initialType?: TargetConfig['type'] // CD → Musik vorwählen (Medium-gerecht)
|
||||
discTitle?: string // Vorschlag für den Serien-Namen
|
||||
deviceId?: string // Laufwerks-ID (sr0) für den Titel-Scan
|
||||
initialType?: TargetConfig['type']
|
||||
discTitle?: string
|
||||
deviceId?: string
|
||||
onClose: () => void
|
||||
onSave: (target: TargetConfig, optionen: RipOptionen) => void
|
||||
}
|
||||
@@ -45,8 +47,6 @@ interface BrowseDir {
|
||||
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) {
|
||||
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
|
||||
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 20–120 s)
|
||||
useEffect(() => {
|
||||
if (scanStatus !== 'running' || !deviceId) return
|
||||
const interval = setInterval(async () => {
|
||||
@@ -78,7 +77,6 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
if (r.data.status === 'done') {
|
||||
const tracks: TitelInfo[] = r.data.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)))
|
||||
setScanStatus('done')
|
||||
} else if (r.data.status === 'error') {
|
||||
@@ -89,6 +87,7 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
}, 3000)
|
||||
return () => clearInterval(interval)
|
||||
}, [scanStatus, deviceId])
|
||||
|
||||
const [targets, setTargets] = useState<TargetConfig[]>([
|
||||
{ id: '1', name: 'Filme', path: '/app/media/movies', type: 'movies', 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 [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||
const [customPath, setCustomPath] = useState('')
|
||||
const { theme } = useDarkMode()
|
||||
|
||||
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
setSelectedType(initialType || 'movies')
|
||||
@@ -144,11 +141,8 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
optionen.series = serienName.trim()
|
||||
optionen.season = Math.max(1, staffel || 1)
|
||||
}
|
||||
// Explizite Titel-Auswahl schlägt alles andere
|
||||
if (scanStatus === 'done' && gewaehlt.size > 0 && gewaehlt.size < titelListe.length) {
|
||||
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)
|
||||
if (customPath) {
|
||||
@@ -159,136 +153,113 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all">
|
||||
<div className={`w-full max-w-lg rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}>
|
||||
{/* Header */}
|
||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Rip-Ziel auswählen</h2>
|
||||
<p className={`text-sm mt-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Rip-Ziel auswählen"
|
||||
maxWidth="lg"
|
||||
>
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Schnellwahl */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{targets.map((t) => (
|
||||
{targets.map((t) => {
|
||||
const isSelected = selectedType === t.type && !customPath
|
||||
return (
|
||||
<button
|
||||
key={t.type}
|
||||
onClick={() => { setSelectedType(t.type); setCustomPath('') }}
|
||||
className={`flex flex-col items-center justify-center gap-1.5 p-3 rounded-lg border transition-all ${
|
||||
selectedType === t.type && !customPath
|
||||
? theme === 'dark'
|
||||
? 'bg-indigo-900/30 border-indigo-500 text-indigo-400'
|
||||
: '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'
|
||||
className={`flex flex-col items-center justify-center gap-1.5 p-3.5 rounded-xl border transition-all ${
|
||||
isSelected
|
||||
? 'bg-amber-500/15 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold shadow-md shadow-amber-500/10'
|
||||
: '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'
|
||||
}`}
|
||||
>
|
||||
<span className="text-xl">
|
||||
{t.type === 'movies' && '🎥'}
|
||||
{t.type === 'series' && '📺'}
|
||||
{t.type === 'music' && '🎵'}
|
||||
</span>
|
||||
<span className="text-sm font-medium">{t.name}</span>
|
||||
{t.type === 'movies' && <Film size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
{t.type === 'series' && <Tv size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
{t.type === 'music' && <Music size={22} className={isSelected ? 'text-amber-500' : ''} />}
|
||||
<span className="text-sm">{t.name}</span>
|
||||
</button>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Serien-Flow: Name + Staffel → Ablage <Serie>/Season NN, Episoden
|
||||
werden nach dem Rip per Laufzeit-Abgleich (TMDB) benannt */}
|
||||
{/* Serien-Flow */}
|
||||
{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>
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Serienname</label>
|
||||
<input
|
||||
<Input
|
||||
label="Serienname"
|
||||
value={serienName}
|
||||
onChange={e => setSerienName(e.target.value)}
|
||||
placeholder="z. B. Neon Genesis Evangelion"
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Staffel</label>
|
||||
<input
|
||||
<Input
|
||||
label="Staffel"
|
||||
type="number"
|
||||
min={1}
|
||||
value={staffel}
|
||||
onChange={e => setStaffel(parseInt(e.target.value) || 1)}
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')} —
|
||||
Episoden werden nach dem Rip per Laufzeit-Abgleich (TMDB) automatisch
|
||||
zu „S{String(Math.max(1, staffel || 1)).padStart(2, '0')}E01…" benannt (nur bei eindeutiger Zuordnung).
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Ablage: {serienName.trim() || '<Serie>'}/Season {String(Math.max(1, staffel || 1)).padStart(2, '0')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hauptfilm-Wahl: pro Rip übersteuerbar (Standard = Einstellung);
|
||||
eine explizite Titel-Auswahl unten schlägt diese Option */}
|
||||
{/* Hauptfilm-Wahl */}
|
||||
{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
|
||||
type="checkbox"
|
||||
checked={nurHauptfilm}
|
||||
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)
|
||||
</label>
|
||||
)}
|
||||
|
||||
{/* Titel-Auswahl (Track-Tabelle): erst scannen, dann abwählen */}
|
||||
{/* Titel-Auswahl (Track-Tabelle) */}
|
||||
{deviceId && selectedType !== 'music' && (
|
||||
<div className={`rounded-lg border ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div className={`flex items-center justify-between px-3 py-2 ${scanStatus === 'done' ? (theme === 'dark' ? 'border-b border-slate-700' : 'border-b border-slate-200') : ''}`}>
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-slate-200 dark:border-slate-800">
|
||||
<div>
|
||||
<span className={`text-sm font-medium ${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' && (
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Disc scannen und einzelne Titel an-/abwählen — statt alles oder nur Hauptfilm.
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Disc scannen und einzelne Titel an-/abwählen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{scanStatus === 'running' ? (
|
||||
<span className={`text-xs flex items-center gap-2 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-indigo-500 border-t-transparent" />
|
||||
<span className="text-xs flex items-center gap-2 text-slate-500 dark:text-slate-400">
|
||||
<span className="animate-spin rounded-full h-3.5 w-3.5 border-2 border-amber-500 border-t-transparent" />
|
||||
Disc wird gelesen (20–120 s)…
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={scanStarten}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-lg transition-colors ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
<Button variant="secondary" size="sm" onClick={scanStarten}>
|
||||
{scanStatus === 'done' ? 'Neu scannen' : 'Disc scannen'}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanStatus === 'error' && (
|
||||
<p className="text-xs text-rose-500 px-3 py-2">{scanFehler}</p>
|
||||
)}
|
||||
{scanStatus === 'done' && titelListe.length === 0 && (
|
||||
<p className={`text-xs px-3 py-2 ${theme === 'dark' ? 'text-amber-400' : 'text-amber-600'}`}>
|
||||
Keine Titel lesbar — vermutlich kann diese Disc nicht entschlüsselt
|
||||
werden (Details stehen nach einem Rip-Versuch im Job-Fehler).
|
||||
</p>
|
||||
<p className="text-xs text-rose-500 px-4 py-2.5">{scanFehler}</p>
|
||||
)}
|
||||
|
||||
{scanStatus === 'done' && titelListe.length > 0 && (
|
||||
<>
|
||||
<div className="max-h-44 overflow-y-auto">
|
||||
{titelListe.map(t => (
|
||||
<label
|
||||
key={t.nr}
|
||||
className={`flex items-center gap-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
|
||||
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)
|
||||
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="flex-shrink-0">{dauerText(t.dauer_s)}</span>
|
||||
<span className="font-mono text-xs w-14 font-semibold text-slate-700 dark:text-slate-300">Titel {t.nr}</span>
|
||||
<span className="text-slate-600 dark:text-slate-300">{dauerText(t.dauer_s)}</span>
|
||||
{t.kapitel > 0 && (
|
||||
<span className={`text-xs ${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` : ''}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<p className={`text-xs px-3 py-2 border-t ${theme === 'dark' ? 'border-slate-700 text-slate-500' : 'border-slate-200 text-slate-400'}`}>
|
||||
<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].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>
|
||||
</>
|
||||
)}
|
||||
@@ -322,80 +291,57 @@ export default function RipTargetModal({ isOpen, initialType, discTitle, deviceI
|
||||
)}
|
||||
|
||||
{/* Ordner-Browser */}
|
||||
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
||||
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
|
||||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden bg-slate-50/50 dark:bg-slate-950/50">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-slate-200 dark:border-slate-800 bg-slate-100/50 dark:bg-slate-900/50">
|
||||
<button
|
||||
onClick={() => browseParent && laden(browseParent)}
|
||||
disabled={!browseParent}
|
||||
className={`p-1 rounded 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"
|
||||
>
|
||||
<ArrowUp size={16} />
|
||||
</button>
|
||||
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
|
||||
<button
|
||||
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||
<Button
|
||||
variant={customPath === browsePath ? 'amber' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => setCustomPath(browsePath)}
|
||||
className={`text-xs font-medium px-2.5 py-1 rounded transition-colors ${customPath === browsePath ? 'bg-indigo-600 text-white' : theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
|
||||
>
|
||||
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-40 overflow-y-auto">
|
||||
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="max-h-36 overflow-y-auto p-1">
|
||||
{browseDirs.map(d => (
|
||||
<button
|
||||
key={d.path}
|
||||
onClick={() => laden(d.path)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-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}
|
||||
</button>
|
||||
))}
|
||||
{browseFiles.map(f => (
|
||||
<div key={f.name} className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<File size={14} className="flex-shrink-0" />
|
||||
<span className="truncate">{f.name}</span>
|
||||
{f.size_mb != null && (
|
||||
<span className="ml-auto text-xs flex-shrink-0">
|
||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gewähltes Ziel */}
|
||||
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
||||
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
{/* Gewähltes Ziel Display */}
|
||||
<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-amber-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
|
||||
<span className="font-mono text-xs truncate text-slate-700 dark:text-slate-300">
|
||||
{customPath || targets.find(t => t.type === selectedType)?.path}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
|
||||
>
|
||||
<div className="flex justify-end gap-3 pt-3 border-t border-slate-200 dark:border-slate-800">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
</Button>
|
||||
<Button variant="amber" onClick={handleSave}>
|
||||
Rippen starten
|
||||
</button>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
+171
-233
@@ -1,12 +1,16 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Clock, Activity, AlertCircle, CheckCircle, Disc, Download, FileSpreadsheet, HardDrive, Trash2 } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
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 DeviceDiscovery from '../components/DeviceDiscovery'
|
||||
import JobDetailModal from '../components/JobDetailModal'
|
||||
import LiveLogSection from '../components/LiveLogSection'
|
||||
import { ENCODER_BADGES } from '../lib/design'
|
||||
|
||||
interface Job {
|
||||
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 }) {
|
||||
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 (
|
||||
<div className="w-32">
|
||||
<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 ${textColors.value}`}>{progress}%</span>
|
||||
<span className="text-xs font-medium text-slate-500 dark:text-slate-400">Fortschritt</span>
|
||||
<span className="text-xs font-mono font-semibold text-slate-900 dark:text-slate-200">{progress}%</span>
|
||||
</div>
|
||||
<div className={`w-full ${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
|
||||
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}%` }}
|
||||
/>
|
||||
@@ -141,13 +59,6 @@ interface WorkerInfo {
|
||||
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() {
|
||||
const [jobs, setJobs] = useState<Job[]>([])
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
@@ -155,7 +66,6 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||
const [aufraeumenOffen, setAufraeumenOffen] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
const jobLoeschen = async (job: Job) => {
|
||||
@@ -207,153 +117,219 @@ export default function Dashboard() {
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8 transition-colors duration-300">
|
||||
{/* Header */}
|
||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
||||
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Dashboard</h1>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Übersicht über alle Ripping-Jobs und Geräte</p>
|
||||
<div className="space-y-8">
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
subtitle="Übersicht über alle Ripping-Jobs, Geräte und Systemauslastung"
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* System-Leiste: Encoder + aktiver Job auf einen Blick */}
|
||||
<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 gap-2">
|
||||
<Activity size={16} className="text-indigo-500" />
|
||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Encoder:</span>
|
||||
<div className="flex-1 min-w-0 text-center md:text-left space-y-2">
|
||||
<div className="flex items-center justify-center md:justify-start gap-2 flex-wrap">
|
||||
<span className="px-2.5 py-0.5 rounded-full text-xs font-bold uppercase tracking-wider bg-amber-500/20 text-amber-300 border border-amber-500/30">
|
||||
{aktiverJob.status === 'transcoding' ? 'Komprimieren' : 'Rippen'} aktiv
|
||||
</span>
|
||||
<TypeBadge type={aktiverJob.type} />
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-slate-100 truncate">
|
||||
{aktiverJob.title || 'Aktiver Job'}
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Gerät: <span className="font-mono text-slate-300">{aktiverJob.device}</span> · Gestartet: {new Date(aktiverJob.startTime).toLocaleTimeString('de-DE')}
|
||||
</p>
|
||||
|
||||
<div className="pt-2">
|
||||
<div className="flex items-center justify-between text-xs font-medium text-slate-300 mb-1">
|
||||
<span>{aktiverJob.status === 'transcoding' ? 'HandBrake Kompression' : 'MakeMKV Extraktion'}</span>
|
||||
<span className="font-mono font-bold text-amber-400">{aktiverJob.progress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-950/80 rounded-full h-2.5 overflow-hidden border border-amber-500/30">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-amber-500 via-indigo-500 to-purple-500 transition-all duration-500 ease-out shadow-lg shadow-amber-500/50"
|
||||
style={{ width: `${aktiverJob.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 flex-shrink-0">
|
||||
<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 ? (
|
||||
<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 => (
|
||||
<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'}`}>
|
||||
{ENCODER_KURZ[e] || e}
|
||||
<span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
|
||||
e.startsWith('cpu')
|
||||
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
|
||||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||
}`}>
|
||||
{ENCODER_BADGES[e]?.label || e}
|
||||
</span>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={16} className="text-indigo-500" />
|
||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Aktiv:</span>
|
||||
|
||||
<div className="flex items-center gap-2.5">
|
||||
<Clock size={17} className="text-amber-500" />
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Aktiv:</span>
|
||||
{aktiverJob ? (
|
||||
<span className={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} %
|
||||
</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>
|
||||
|
||||
{plaetze.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive size={16} className="text-indigo-500" />
|
||||
<span className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>Frei:</span>
|
||||
<div className="flex items-center gap-2.5 ml-auto">
|
||||
<HardDrive size={17} className="text-amber-500" />
|
||||
<span className="text-slate-500 dark:text-slate-400 font-medium">Frei:</span>
|
||||
{plaetze.map(p => (
|
||||
<span
|
||||
key={p.name}
|
||||
title={`${p.name}: ${p.frei_gb} von ${p.gesamt_gb} GB frei`}
|
||||
className={`text-xs px-2 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
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Laufwerke + erkannte Disc */}
|
||||
{/* Laufwerke & Erkennung */}
|
||||
<DeviceDiscovery />
|
||||
|
||||
{/* 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
|
||||
icon={Clock}
|
||||
label="Warteschlange"
|
||||
value={stats.pending.toString()}
|
||||
color="amber"
|
||||
darkMode={theme === 'dark'}
|
||||
badgeStyle="bg-amber-500/15 text-amber-500 border-amber-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label="Bearbeitung"
|
||||
value={stats.processing.toString()}
|
||||
color="blue"
|
||||
darkMode={theme === 'dark'}
|
||||
badgeStyle="bg-sky-500/15 text-sky-500 border-sky-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle}
|
||||
label="Fertig"
|
||||
value={stats.completed.toString()}
|
||||
color="emerald"
|
||||
darkMode={theme === 'dark'}
|
||||
badgeStyle="bg-emerald-500/15 text-emerald-500 border-emerald-500/20"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertCircle}
|
||||
label="Fehler"
|
||||
value={stats.failed.toString()}
|
||||
color="rose"
|
||||
darkMode={theme === 'dark'}
|
||||
badgeStyle="bg-rose-500/15 text-rose-500 border-rose-500/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Jobs */}
|
||||
{/* Schnellaktionen + doppelte Geräte-Sektion sind raus (23.07.): alle
|
||||
vier Knöpfe und der zweite Details-Button waren funktionslos —
|
||||
die echte Geräte-Steuerung lebt in DeviceDiscovery oben. */}
|
||||
<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>
|
||||
{/* Recent Jobs Table */}
|
||||
<Card className="overflow-hidden">
|
||||
<CardHeader>
|
||||
<CardTitle>Neueste Jobs</CardTitle>
|
||||
<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 && (
|
||||
<a
|
||||
href="/api/jobs/export"
|
||||
download
|
||||
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} />
|
||||
CSV
|
||||
</a>
|
||||
)}
|
||||
{jobs.some(j => j.status === 'completed' || j.status === 'failed') && (
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setAufraeumenOffen(true)}
|
||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen (Dateien bleiben)"
|
||||
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'}`}
|
||||
title="Alle fertigen und fehlgeschlagenen Jobs aus der Liste entfernen"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
Erledigte aufräumen
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="overflow-x-auto transition-colors duration-300">
|
||||
<table className="w-full transition-colors duration-300">
|
||||
<thead className={theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}>
|
||||
</CardHeader>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<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 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 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 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 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 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 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">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">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">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">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">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">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">Aktion</th>
|
||||
</tr>
|
||||
</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 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-6 py-12 text-center transition-colors duration-300">
|
||||
<div className={`mb-2 ${theme === 'dark' ? 'text-slate-400' : '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>
|
||||
<td colSpan={7} className="px-6 py-12 text-center">
|
||||
<div className="mb-1 font-semibold text-slate-500 dark:text-slate-400">Noch keine Jobs</div>
|
||||
<p className="text-sm text-slate-400 dark:text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
jobs.slice(0, 10).map(job => (
|
||||
<tr key={job.id} className={`transition-colors 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">
|
||||
<TypeBadge type={job.type} />
|
||||
</td>
|
||||
@@ -361,7 +337,7 @@ export default function Dashboard() {
|
||||
<button
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
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'}
|
||||
</button>
|
||||
@@ -369,58 +345,59 @@ export default function Dashboard() {
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<StatusBadge status={job.status} />
|
||||
</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}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<ProgressBar progress={job.progress} />
|
||||
</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')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
{job.status === 'completed' && (
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setDetailJobId(job.id)}
|
||||
title="Fertige Dateien herunterladen (öffnet die 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'}`}
|
||||
title="Fertige Dateien herunterladen (öffnet Job-Details)"
|
||||
>
|
||||
<Download size={13} />
|
||||
<Download size={13} className="text-emerald-500" />
|
||||
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 && (
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
api.post(`/jobs/${job.id}/retry-transcode`)
|
||||
.then(() => toast('success', 'Kompression neu eingereiht'))
|
||||
.catch((e: any) => toast('error', e?.response?.data?.detail || 'Neu anstoßen fehlgeschlagen'))
|
||||
}}
|
||||
title="Kompression neu anstoßen (Rohdatei bleibt nach Fehlschlägen erhalten)"
|
||||
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'}`}
|
||||
title="Kompression neu anstoßen"
|
||||
>
|
||||
Neu komprimieren
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{(job.status === 'completed' || job.status === 'failed') && (
|
||||
<button
|
||||
onClick={() => jobLoeschen(job)}
|
||||
title="Job aus der Liste entfernen — fertige Dateien bleiben liegen"
|
||||
className={`ml-2 p-1.5 rounded-lg transition-colors text-rose-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-100'}`}
|
||||
title="Job aus der Liste entfernen"
|
||||
className="ml-2 p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
{(job.status === 'pending' || job.status === 'processing' || job.status === 'transcoding') && (
|
||||
<button
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => api.post(`/jobs/${job.id}/cancel`).catch(() => {})}
|
||||
title="Job abbrechen (Rohdaten bleiben erhalten)"
|
||||
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'}`}
|
||||
title="Job abbrechen"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -429,12 +406,10 @@ export default function Dashboard() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
||||
<LiveLogSection />
|
||||
|
||||
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
|
||||
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||
|
||||
<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 }) {
|
||||
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',
|
||||
}
|
||||
|
||||
function StatCard({ icon: Icon, label, value, badgeStyle }: { icon: any, label: string, value: string, badgeStyle: string }) {
|
||||
return (
|
||||
<div className={`${bgColors.card} rounded-xl shadow-sm border p-6 hover:shadow-md transition-shadow`}>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`${bgColors.iconBg[color as keyof typeof bgColors.iconBg]} p-3 rounded-lg`}>
|
||||
<Icon className={`${bgColors.iconText[color as keyof typeof bgColors.iconText]} w-6 h-6`} />
|
||||
<Card>
|
||||
<CardContent className="p-5 flex items-center gap-4">
|
||||
<div className={`p-3 rounded-xl border ${badgeStyle}`}>
|
||||
<Icon className="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className={`text-sm font-medium ${bgColors.textLabel}`}>{label}</p>
|
||||
<p className={`text-3xl font-bold ${bgColors.textValue}`}>{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-slate-500 dark:text-slate-400">{label}</p>
|
||||
<p className="text-3xl font-bold text-slate-900 dark:text-slate-100">{value}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
+251
-414
@@ -1,10 +1,15 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
||||
import { api } from '../lib/api'
|
||||
import { useDarkMode } from '../context/ThemeContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
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 {
|
||||
tmdbApiKey: string
|
||||
@@ -68,15 +73,6 @@ interface SystemInfo {
|
||||
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() {
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||
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 [updatesBusy, setUpdatesBusy] = useState(false)
|
||||
|
||||
const { toast } = useToast()
|
||||
|
||||
const updatesPruefen = async () => {
|
||||
setUpdatesBusy(true)
|
||||
try {
|
||||
@@ -107,8 +105,6 @@ export default function SettingsPage() {
|
||||
setUpdatesBusy(false)
|
||||
}
|
||||
}
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
const quellenPruefen = async () => {
|
||||
setQuellenBusy(true)
|
||||
@@ -154,7 +150,6 @@ export default function SettingsPage() {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const response = await api.get('/settings')
|
||||
// Leeres Objekt von der API = noch nichts gespeichert → Defaults behalten
|
||||
setSettings({ ...defaultSettings, ...response.data })
|
||||
} catch {
|
||||
setSettings(defaultSettings)
|
||||
@@ -187,199 +182,177 @@ export default function SettingsPage() {
|
||||
if (loading) {
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className={`max-w-4xl space-y-6 transition-colors duration-300 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<div className="flex flex-col gap-2 transition-colors duration-300">
|
||||
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Einstellungen</h1>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Konfiguriere Rippy nach deinen Wünschen</p>
|
||||
</div>
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<PageHeader
|
||||
title="Einstellungen"
|
||||
subtitle="Konfiguriere Ripping-Parameter, Speicherziele, APIs und Benachrichtigungen"
|
||||
/>
|
||||
|
||||
{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'}`}>
|
||||
<CheckCircle className="flex-shrink-0" size={24} />
|
||||
<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={22} />
|
||||
<span>Einstellungen erfolgreich gespeichert!</span>
|
||||
</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'}`}>
|
||||
{/* Tabs — echte Navigation (vorher reine Dekoration ohne onClick) */}
|
||||
<div className={`flex transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} border-b`}>
|
||||
<TabButton icon={Disc} label="Ripping" isActive={activeTab === 'ripping'} onClick={() => setActiveTab('ripping')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Cpu} label="Verarbeitung" isActive={activeTab === 'verarbeitung'} onClick={() => setActiveTab('verarbeitung')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Cpu} label="Worker" isActive={activeTab === 'worker'} onClick={() => setActiveTab('worker')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} />
|
||||
<Card className="overflow-hidden">
|
||||
{/* Navigation Tabs */}
|
||||
<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">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
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>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-8 transition-colors duration-300">
|
||||
{/* Tab Content */}
|
||||
<CardContent className="p-6 space-y-8">
|
||||
{/* Ripping Settings */}
|
||||
{activeTab === 'ripping' && <section>
|
||||
<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'}`}>
|
||||
<Disc size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{activeTab === 'ripping' && (
|
||||
<section className="space-y-6">
|
||||
<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
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6 transition-colors duration-300">
|
||||
{/* ── Video: DVD · Blu-ray · 4K UHD ─────────────────────── */}
|
||||
<p className={`text-xs font-semibold uppercase tracking-wider ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs font-bold uppercase tracking-wider text-slate-400 dark:text-slate-500">
|
||||
📀 Video — DVD · Blu-ray · 4K UHD
|
||||
</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="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>
|
||||
<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="Vollautomatik: sofort rippen"
|
||||
description="Disc erkannt → Rip startet ohne Nachfrage in den passenden Ordner."
|
||||
checked={settings.autoRipStart}
|
||||
onChange={(v) => handleChange('autoRipStart', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</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="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>
|
||||
<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="Nur Hauptfeature"
|
||||
description="Rippe bei DVDs/Blu-rays nur das Hauptfeature (längster Titel)."
|
||||
checked={settings.mainFeatureOnly}
|
||||
onChange={(v) => handleChange('mainFeatureOnly', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p className={`text-xs px-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und
|
||||
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 className="text-xs text-slate-500 dark:text-slate-400 px-1">
|
||||
🌐 Sprachen & Untertitel: MakeMKV sichert grundsätzlich ALLE Ton-Spuren und Untertitel verlustfrei.
|
||||
</p>
|
||||
|
||||
{/* ── Audio-CD ──────────────────────────────────────────── */}
|
||||
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||
🎵 Audio-CD
|
||||
</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="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>
|
||||
<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="Alle Tracks rippen"
|
||||
description="Rippe alle Tracks einer CD (FLAC + MusicBrainz-Tags)."
|
||||
checked={settings.ripAllTracks}
|
||||
onChange={(v) => handleChange('ripAllTracks', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ── Allgemein ─────────────────────────────────────────── */}
|
||||
<p className={`text-xs font-semibold uppercase tracking-wider pt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
<p className="text-xs font-bold uppercase tracking-wider pt-2 text-slate-400 dark:text-slate-500">
|
||||
Allgemein
|
||||
</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="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>
|
||||
<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="Automatischer Auswurf"
|
||||
description="Disc nach erfolgreichem Ripping automatisch auswerfen."
|
||||
checked={settings.autoEject}
|
||||
onChange={(v) => handleChange('autoEject', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
|
||||
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* Media-Server Integration */}
|
||||
<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-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Tv size={18} className="text-amber-500" />
|
||||
Media-Server (Jellyfin & Co.)
|
||||
</h3>
|
||||
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt — je nach System —
|
||||
movie.nfo + poster.jpg dazu. Dein Server erkennt den Film dann ohne Nacharbeit.
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt NFO + Poster dazu.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||
<button
|
||||
key={o.wert}
|
||||
type="button"
|
||||
onClick={() => handleChange('mediaServer', o.wert)}
|
||||
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
|
||||
? 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'
|
||||
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||||
: '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'
|
||||
}`}
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
))}
|
||||
</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}
|
||||
</p>
|
||||
|
||||
{/* Jellyfin/Emby: nach jedem fertigen Rip die Bibliothek scannen */}
|
||||
{(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'}`}>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
||||
<strong>Bibliotheks-Refresh</strong> (optional): Trag Server-URL + API-Key ein,
|
||||
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.
|
||||
<div className="pt-3 border-t border-slate-200 dark:border-slate-800 space-y-3">
|
||||
<p className="text-xs text-slate-600 dark:text-slate-300">
|
||||
<strong>Bibliotheks-Refresh</strong>: Server-URL + API-Key für automatischen Scan nach dem Rip.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
<input
|
||||
type="text"
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 items-end">
|
||||
<Input
|
||||
label="Server-URL"
|
||||
value={settings.jellyfinUrl}
|
||||
onChange={(e) => handleChange('jellyfinUrl', e.target.value)}
|
||||
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">
|
||||
<input
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
type="password"
|
||||
label="API-Key"
|
||||
value={settings.jellyfinApiKey}
|
||||
onChange={(e) => handleChange('jellyfinApiKey', e.target.value)}
|
||||
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 () => {
|
||||
try {
|
||||
await api.post('/mediaserver/refresh', {
|
||||
@@ -391,41 +364,44 @@ export default function SettingsPage() {
|
||||
}
|
||||
}}
|
||||
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
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Verarbeitung: Kompressions-Stufe nach dem Rip */}
|
||||
{activeTab === 'verarbeitung' && <section>
|
||||
<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'}`}>
|
||||
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* Verarbeitung */}
|
||||
{activeTab === 'verarbeitung' && (
|
||||
<section className="space-y-6">
|
||||
<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)
|
||||
</h2>
|
||||
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
MakeMKV rippt verlustfrei (der einzige Weg durch den Kopierschutz), HandBrake
|
||||
komprimiert danach auf Arbeitsgröße. Die Roh-Datei wird nach Erfolg gelöscht.
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
MakeMKV rippt verlustfrei, HandBrake komprimiert danach auf Arbeitsgröße.
|
||||
</p>
|
||||
|
||||
{/* Ehrliche Selbstauskunft: was können die Worker WIRKLICH? */}
|
||||
<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 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Verfügbare Encoder</p>
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<p className="text-sm font-medium mb-2 text-slate-900 dark:text-slate-200">Verfügbare Encoder</p>
|
||||
{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 => (
|
||||
<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 => (
|
||||
<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'}`}>
|
||||
{ENCODER_LABELS[e] || e}
|
||||
<span key={e} className={`inline-block text-xs px-2.5 py-0.5 rounded-md font-medium border mr-1 ${
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
@@ -433,132 +409,115 @@ export default function SettingsPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
<div className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<div>
|
||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Nach dem Rip komprimieren</h3>
|
||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>x265 statt 40-GB-Rohdatei (empfohlen)</p>
|
||||
</div>
|
||||
<Toggle checked={settings.transcodeEnabled} onChange={(v) => handleChange('transcodeEnabled', v)} darkMode={theme === 'dark'} />
|
||||
<div className="space-y-4">
|
||||
<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="Nach dem Rip komprimieren"
|
||||
description="x265 statt 40-GB-Rohdatei (empfohlen)."
|
||||
checked={settings.transcodeEnabled}
|
||||
onChange={(v) => handleChange('transcodeEnabled', v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
HandBrake-Preset
|
||||
</label>
|
||||
<select
|
||||
<Select
|
||||
label="HandBrake-Preset"
|
||||
value={settings.transcodePreset}
|
||||
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 & modern (Standard)</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="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 className={`flex items-center justify-between p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<div>
|
||||
<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"
|
||||
<Input
|
||||
label="Arbeitsverzeichnis für Roh-Rips (optional)"
|
||||
value={settings.workDir}
|
||||
onChange={(e) => handleChange('workDir', e.target.value)}
|
||||
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'}`}>
|
||||
Muss unter /app/media liegen (z. B. /app/media/nas-arbeit — Freigabe vorher unter
|
||||
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.
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Wichtig für 4K-UHD: Rohdaten sind bis 100 GB groß.
|
||||
{systemInfo?.plaetze?.length ? (
|
||||
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Worker: Encoding-Maschinen im Netz */}
|
||||
{activeTab === 'worker' && <section>
|
||||
<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'}`}>
|
||||
<Cpu size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* Worker */}
|
||||
{activeTab === 'worker' && (
|
||||
<section className="space-y-6">
|
||||
<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
|
||||
</h2>
|
||||
<WorkerVerwaltung />
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Speicherziele: Netzwerk-Mounts aus dem UI */}
|
||||
{activeTab === 'speicherziele' && <section>
|
||||
<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'}`}>
|
||||
<HardDrive size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* Speicherziele */}
|
||||
{activeTab === 'speicherziele' && (
|
||||
<section className="space-y-6">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
{/* Schnellwahl-Ordner: die drei Knöpfe im "Rippen starten"-Dialog */}
|
||||
<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-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<Database size={16} className="text-indigo-500" /> Schnellwahl beim Rippen
|
||||
<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">
|
||||
<h3 className="font-semibold flex items-center gap-2 text-slate-900 dark:text-slate-100">
|
||||
<Database size={16} className="text-amber-500" /> Schnellwahl beim Rippen
|
||||
</h3>
|
||||
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||
Beim Start eines Rips fragt Rippy „Wohin?" — diese drei Ordner erscheinen
|
||||
dort als 🎥/📺/🎵-Schnellwahl. Hier legst du nur fest, wie sie heißen.
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Name der Ordner im „Rippen starten"-Dialog (🎥/📺/🎵).
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{([['movieDir', '🎥 Filme'], ['seriesDir', '📺 Serien'], ['musicDir', '🎵 Musik']] as const).map(([feld, label]) => (
|
||||
<div key={feld}>
|
||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{label}</label>
|
||||
<input
|
||||
type="text"
|
||||
<Input
|
||||
key={feld}
|
||||
label={label}
|
||||
value={settings[feld]}
|
||||
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>
|
||||
|
||||
<StorageMounts />
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* API Keys */}
|
||||
{activeTab === 'apis' && <section>
|
||||
<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'}`}>
|
||||
<Globe size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* APIs */}
|
||||
{activeTab === 'apis' && (
|
||||
<section className="space-y-6">
|
||||
<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
|
||||
</h2>
|
||||
|
||||
{/* Live-Prüfung: funktioniert der eingetragene Key WIRKLICH? */}
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<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">
|
||||
<Button variant="amber" size="sm" onClick={quellenPruefen} disabled={quellenBusy}>
|
||||
{quellenBusy ? 'Prüfe…' : 'Verbindung prüfen'}
|
||||
</button>
|
||||
</Button>
|
||||
{quellenStatus ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{Object.entries(quellenStatus).map(([quelle, status]) => (
|
||||
<span
|
||||
key={quelle}
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium ${
|
||||
status === 'ok' ? 'bg-emerald-600/20 text-emerald-500'
|
||||
: status === 'kein_key' ? (theme === 'dark' ? 'bg-slate-800 text-slate-400' : 'bg-slate-200 text-slate-500')
|
||||
: 'bg-rose-600/20 text-rose-500'
|
||||
className={`text-xs px-2.5 py-1 rounded-full font-medium border ${
|
||||
status === 'ok' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
|
||||
: status === 'kein_key' ? 'bg-slate-200 dark:bg-slate-800 text-slate-500 border-slate-300 dark:border-slate-700'
|
||||
: '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'}
|
||||
@@ -566,295 +525,173 @@ export default function SettingsPage() {
|
||||
))}
|
||||
</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.
|
||||
Beide TMDB-Key-Arten funktionieren (v3-Schlüssel und v4-Token).
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 transition-colors duration-300">
|
||||
<div className="space-y-2 transition-colors duration-300">
|
||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
TMDB API Key
|
||||
</label>
|
||||
<input
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
type="password"
|
||||
label="TMDB API Key"
|
||||
value={settings.tmdbApiKey}
|
||||
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"
|
||||
/>
|
||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
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
|
||||
<Input
|
||||
type="password"
|
||||
label="TVDb API Key"
|
||||
value={settings.tvdbApiKey}
|
||||
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"
|
||||
/>
|
||||
</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'}`}>
|
||||
OMDb API Key (Fallback)
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
type="password"
|
||||
label="OMDb API Key (Fallback)"
|
||||
value={settings.omdbApiKey}
|
||||
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"
|
||||
/>
|
||||
<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>
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
||||
{activeTab === 'benachrichtigungen' && <section>
|
||||
<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'}`}>
|
||||
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* Benachrichtigungen */}
|
||||
{activeTab === 'benachrichtigungen' && (
|
||||
<section className="space-y-6">
|
||||
<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
|
||||
</h2>
|
||||
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder
|
||||
<strong> abgebrochen</strong> wird — direkt in deinen Chat. Der Dienst wird an der URL
|
||||
automatisch erkannt, du musst nichts weiter einstellen.
|
||||
<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 <strong>abgebrochen</strong> wird.
|
||||
</p>
|
||||
|
||||
{/* Schritt-für-Schritt: so kommst du an eine Webhook-URL */}
|
||||
<div className={`rounded-lg p-4 mb-4 space-y-3 text-sm ${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 space-y-3 text-sm">
|
||||
<div>
|
||||
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>💬 Discord (einfachster Weg)</p>
|
||||
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
||||
Server → Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" →
|
||||
„Webhook-URL kopieren" und unten einfügen.
|
||||
<p className="font-semibold text-slate-900 dark:text-slate-200">💬 Discord (einfachster Weg)</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Kanal-Zahnrad → Integrationen → Webhooks → „Neuer Webhook" → URL kopieren.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className={`font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>📱 ntfy (Push aufs Handy, ohne Account)</p>
|
||||
<p className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}>
|
||||
ntfy-App installieren, ein Thema abonnieren (z. B. „rippy-tobi-4711") und unten
|
||||
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 className="font-semibold text-slate-900 dark:text-slate-200">📱 ntfy (Push aufs Handy)</p>
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
Thema abonnieren und https://ntfy.sh/mein-thema eintragen.
|
||||
</p>
|
||||
</div>
|
||||
</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'}`}>
|
||||
Webhook URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
<div className="space-y-2">
|
||||
<div className="flex gap-2 items-end">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="Webhook URL"
|
||||
value={settings.notificationWebhook}
|
||||
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"
|
||||
/>
|
||||
<button
|
||||
</div>
|
||||
<Button
|
||||
variant="amber"
|
||||
size="md"
|
||||
onClick={webhookTesten}
|
||||
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} />
|
||||
{testBusy ? 'Sende…' : 'Test senden'}
|
||||
</button>
|
||||
</Button>
|
||||
</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>
|
||||
</section>}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* System: Kern-Werkzeuge, Versionen, MakeMKV-Key, Speicherplatz */}
|
||||
{activeTab === 'system' && <section>
|
||||
<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'}`}>
|
||||
<Wrench size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
{/* System */}
|
||||
{activeTab === 'system' && (
|
||||
<section className="space-y-6">
|
||||
<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 & Kern-Werkzeuge
|
||||
</h2>
|
||||
|
||||
{/* Werkzeug-Versionen je Worker — ehrlich gemeldet, nicht behauptet */}
|
||||
<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 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>Installierte Werkzeuge</p>
|
||||
<div className="p-4 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-950/80">
|
||||
<p className="text-sm font-semibold mb-2 text-slate-900 dark:text-slate-200">Installierte Werkzeuge</p>
|
||||
{!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 => (
|
||||
<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 && (
|
||||
<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}
|
||||
</span>
|
||||
)}
|
||||
{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}
|
||||
</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>
|
||||
))
|
||||
)}
|
||||
{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>
|
||||
|
||||
{/* MakeMKV-Beta-Key: der Monats-Key, OHNE Rebuild aktualisierbar */}
|
||||
<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
|
||||
<Input
|
||||
type="password"
|
||||
label="MakeMKV Beta-Key"
|
||||
value={settings.makemkvAppKey}
|
||||
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 && docker compose up -d worker</code> (siehe README).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Update-Check: Rippy MELDET Updates ehrlich — selbst updaten
|
||||
kann es nicht (die Versionen stecken im Worker-Image) */}
|
||||
<div className={`rounded-lg p-4 mt-6 ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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"
|
||||
>
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm font-semibold text-slate-900 dark:text-slate-200">Update-Check</p>
|
||||
<Button variant="secondary" size="sm" onClick={updatesPruefen} disabled={updatesBusy}>
|
||||
{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>
|
||||
{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 && docker compose up -d worker</code>.
|
||||
Neue Version bringt auch die neueste Disc-Schlüssel-Datenbank mit.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className={theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}>
|
||||
<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>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{/* Save Button — nicht im Worker-Tab: dort gibt es nichts zu
|
||||
speichern (Liste + Anbinde-Befehle), der Knopf verwirrte nur */}
|
||||
{/* Global Save Button */}
|
||||
{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`}>
|
||||
<button
|
||||
<div className="flex items-center justify-end gap-4 pt-6 border-t border-slate-200/80 dark:border-slate-800">
|
||||
<Button
|
||||
variant="amber"
|
||||
size="lg"
|
||||
onClick={handleSave}
|
||||
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 ? (
|
||||
<>
|
||||
<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...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save size={20} />
|
||||
<Save size={18} />
|
||||
Einstellungen speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user