UX-Runde: echte Disc-Titel via BD-Metadaten, Preview-Crash, Dashboard, Ziele
Ampel / ampel (push) Failing after 38s

Commander-Befunde 23.07. abends:
1. Metadaten "klappen nicht": Wurzel = kryptisches Volume-Label + kein
   TMDB-Key. Jetzt: API mountet die Disc read-only (CAP_SYS_ADMIN ist da)
   und liest den KLARTEXT-Titel aus BDMV/META/DL/bdmt_*.xml; dazu
   progressive Suchdegradation (Titel-Varianten) und OMDb-Unscharf-Suche
   (s= + imdbID-Nachladen) — mit dem vorhandenen OMDb-Key gibt es damit
   Titel/Jahr/Poster auch ohne TMDB
2. Weisses Fenster beim Pre-Scan: Lucide-Icons sind forwardRef —
   Direktaufruf getIcon(...)({size}) crashte die Seite; als JSX gerendert
3. Dashboard sortiert: System-Leiste (Encoder + aktiver Job) oben,
   Disc/Geraete -> Stats -> Jobs, Live-Log ans Ende
4. Ziel-Dialog mit ORDNER-BROWSER (GET /browse, nur unter /app/media);
   Schnellwahl nutzt die Settings-Unterordner statt Hartkodierung;
   Tab "Verzeichnisse" in "Speicherziele" aufgegangen (redundant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-23 21:07:55 +02:00
parent 3d384c1e86
commit 90eb17d407
7 changed files with 348 additions and 163 deletions
+115 -73
View File
@@ -1,4 +1,6 @@
import { useState } from 'react'
import { useState, useEffect } from 'react'
import { Folder, FolderOpen, ArrowUp, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
interface TargetConfig {
@@ -15,24 +17,58 @@ interface RipTargetModalProps {
onSave: (target: TargetConfig) => void
}
interface BrowseDir {
name: string
path: string
}
// Ziel-Auswahl vor dem Rip: Schnellwahl (Filme/Serien/Musik) ODER frei per
// Ordner-Browser — auch in eingehängte Netzwerk-Ziele (NAS, PC-Freigabe).
export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetModalProps) {
const [selectedType, setSelectedType] = useState<TargetConfig['type']>('movies')
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 },
{ id: '3', name: 'Musik', path: '/app/media/music', type: 'music', isActive: true },
])
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [customPath, setCustomPath] = useState('')
const { theme } = useDarkMode()
const defaultTargets: TargetConfig[] = [
{ id: '1', name: 'Movies', path: '/app/media/movies', type: 'movies', isActive: true },
{ id: '2', name: 'Series', path: '/app/media/series', type: 'series', isActive: true },
{ id: '3', name: 'Music', path: '/app/media/music', type: 'music', isActive: true },
]
// Standard-Unterordner aus den Einstellungen ziehen (nicht hartkodiert)
useEffect(() => {
if (!isOpen) return
api.get('/settings').then(r => {
const s = r.data || {}
const basis = s.outputDir || '/app/media'
setTargets([
{ id: '1', name: 'Filme', path: `${basis}/${s.movieDir || 'movies'}`, type: 'movies', isActive: true },
{ id: '2', name: 'Serien', path: `${basis}/${s.seriesDir || 'series'}`, type: 'series', isActive: true },
{ id: '3', name: 'Musik', path: `${basis}/${s.musicDir || 'music'}`, type: 'music', isActive: true },
])
}).catch(() => {})
laden('/app/media')
}, [isOpen])
const [targets, setTargets] = useState(defaultTargets)
const laden = async (pfad: string) => {
try {
const r = await api.get('/browse', { params: { path: pfad } })
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
} catch {
setBrowseDirs([])
}
}
const handleSave = () => {
const target = targets.find(t => t.type === selectedType)
if (target) {
// Eigener Pfad gewinnt — vorher wurde customPath stillschweigend ignoriert
onSave(customPath ? { ...target, path: customPath } : target)
if (customPath) {
onSave({ id: 'custom', name: 'Eigener Ordner', path: customPath, type: selectedType, isActive: true })
} else if (target) {
onSave(target)
}
onClose()
}
@@ -46,75 +82,81 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
<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'}`}>
Wohin sollen die gerippten Dateien gespeichert werden?
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-6">
{/* Type Selection */}
<div>
<label className={`block text-sm font-medium mb-3 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Dateityp
</label>
<div className="grid grid-cols-3 gap-3">
{['movies', 'series', 'music'].map((type) => (
<button
key={type}
onClick={() => setSelectedType(type as TargetConfig['type'])}
className={`flex flex-col items-center justify-center gap-2 p-4 rounded-lg border transition-all ${
selectedType === type
? 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'
}`}
>
<span className="text-2xl">
{type === 'movies' && '🎥'}
{type === 'series' && '📺'}
{type === 'music' && '🎵'}
</span>
<span className="text-sm font-medium capitalize">{type}</span>
</button>
))}
<div className="p-6 space-y-5">
{/* Schnellwahl */}
<div className="grid grid-cols-3 gap-3">
{targets.map((t) => (
<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'
}`}
>
<span className="text-xl">
{t.type === 'movies' && '🎥'}
{t.type === 'series' && '📺'}
{t.type === 'music' && '🎵'}
</span>
<span className="text-sm font-medium">{t.name}</span>
</button>
))}
</div>
{/* Ordner-Browser */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className={`flex items-center gap-2 px-3 py-2 border-b ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}>
<button
onClick={() => browseParent && laden(browseParent)}
disabled={!browseParent}
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
title="Ordner hoch"
>
<ArrowUp size={16} />
</button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
<button
onClick={() => setCustomPath(browsePath)}
className={`text-xs font-medium px-2.5 py-1 rounded transition-colors ${customPath === browsePath ? 'bg-indigo-600 text-white' : theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
>
{customPath === browsePath ? '✓ gewählt' : 'Diesen Ordner nutzen'}
</button>
</div>
<div className="max-h-40 overflow-y-auto">
{browseDirs.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
) : (
browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))
)}
</div>
</div>
{/* Path Display */}
<div>
<label className={`block text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
Zielverzeichnis
</label>
<div className={`flex items-center gap-3 p-4 rounded-lg border ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`}>
<span className="text-xl">📁</span>
<input
type="text"
value={customPath || targets.find(t => t.type === selectedType)?.path || ''}
onChange={(e) => setCustomPath(e.target.value)}
className={`flex-1 bg-transparent outline-none text-sm font-mono ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}
placeholder="/path/to/target"
/>
<button
onClick={() => {
const target = targets.find(t => t.type === selectedType)
if (target) {
setCustomPath(target.path)
}
}}
className={`px-3 py-1.5 rounded text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-200 text-slate-700 hover:bg-slate-300'}`}
>
Standard
</button>
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}>
{customPath
? `Verwende benutzerdefiniertes Verzeichnis: ${customPath}`
: `Verwende Standard-Verzeichnis: ${targets.find(t => t.type === selectedType)?.path}`
}
</p>
{/* Gewähltes Ziel */}
<div className={`flex items-center gap-2 text-sm px-3 py-2 rounded-lg ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
{customPath ? <FolderOpen size={16} className="text-indigo-500" /> : <CheckCircle size={16} className="text-emerald-500" />}
<span className={`font-mono text-xs truncate ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{customPath || targets.find(t => t.type === selectedType)?.path}
</span>
</div>
</div>
@@ -130,7 +172,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
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"
>
Speichern
Rippen starten
</button>
</div>
</div>