feat(ui): Toast-Feedback ueberall, Job-Detail-Popup, System-Tab, Wizard-Media-Server, Favicon, Footer

- ToastContext: jede Aktion quittiert sichtbar ('Erfolgreich aktualisiert'
  ploppt oben rechts rein) — Refresh-Knoepfe drehen jetzt wirklich
  (Laufwerke, Logs, Worker, Speicherziele).
- JobDetailModal: Klick auf Titel in 'Neueste Jobs' -> Poster, Jahr,
  Beschreibung, Genres, Ablagepfad, Fehler.
- Logs: Filter-Pills um Warnung + Fehler ergaenzt (vorher nur
  Alle/Info/Erfolg).
- Speicherziele: Benutzer/Passwort VOR dem 'Freigaben auflisten'-Knopf
  (Windows/NAS lehnen Gast-Abfragen ab — genau daher kam das
  NT_STATUS_ACCESS_DENIED), Hinweistext, Toasts beim Einhaengen/Entfernen.
- Datei-Browser (Speicherziele + Rip-Ziel-Dialog) zeigt Dateien grau mit
  Groesse — Ordner wirkten faelschlich leer.
- Einstellungen: neuer System-Tab (Werkzeug-Versionen je Worker, Key-Status,
  Plattenplatz, MakeMKV-Beta-Key-Feld), Media-Server-Wahl im Ripping-Tab,
  Arbeitsverzeichnis im Verarbeitung-Tab, Benachrichtigungs-Anleitung
  (Discord/ntfy/Slack) + 'Test senden'.
- First-Run-Wizard: Media-Server-Schritt (Jellyfin/Emby/Kodi/Plex/Keins).
- Favicon (Disc im Logo-Verlauf, public/favicon.svg — /vite.svg war 404)
  + Footer 'Created with (Herz) by LucyAI, Claude and KrBrZ'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-07-24 08:55:42 +02:00
parent e032a9df1c
commit 13c1ae6064
16 changed files with 722 additions and 76 deletions
+20 -5
View File
@@ -2,6 +2,7 @@ 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 RipTargetModal from './RipTargetModal'
import MetadataKorrektur from './MetadataKorrektur'
@@ -38,21 +39,29 @@ function posterUrl(disc?: DiscInfo): string | null {
export default function DeviceDiscovery() {
const [devices, setDevices] = useState<Device[]>([])
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
const [expandedId, setExpandedId] = useState<string | null>(null)
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
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 () => {
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')
setDevices(response.data)
if (manuell) toast('success', 'Laufwerke aktualisiert')
} catch (error) {
console.error('Fehler beim Laden der Geräte:', error)
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
} finally {
setLoading(false)
if (manuell) setTimeout(() => setRefreshing(false), 400)
}
}
@@ -65,8 +74,11 @@ export default function DeviceDiscovery() {
...(targetDir ? { target_dir: targetDir } : {}),
})
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
} catch (error: any) {
setActionFeedback(`${error?.response?.data?.detail || 'Job konnte nicht angelegt werden'}`)
const detail = error?.response?.data?.detail || 'Job konnte nicht angelegt werden'
setActionFeedback(`${detail}`)
toast('error', detail)
} finally {
setActionBusy(false)
}
@@ -78,9 +90,12 @@ export default function DeviceDiscovery() {
try {
await api.post(`/devices/${device.id}/eject`)
setActionFeedback('✓ Disc ausgeworfen')
toast('success', 'Disc ausgeworfen')
refreshDevices()
} catch (error: any) {
setActionFeedback(`${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`)
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
setActionFeedback(`${detail}`)
toast('error', detail)
} finally {
setActionBusy(false)
}
@@ -135,11 +150,11 @@ export default function DeviceDiscovery() {
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
</div>
<button
onClick={refreshDevices}
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 ? 'animate-spin' : ''} />
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
</button>
</div>
+39 -1
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, CheckCircle, ArrowRight } from 'lucide-react'
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
@@ -22,6 +23,7 @@ const ENCODER_LABELS: Record<string, string> = {
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [tmdbKey, setTmdbKey] = useState('')
const [omdbKey, setOmdbKey] = useState('')
const [mediaServer, setMediaServer] = useState('jellyfin')
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
const [preset, setPreset] = useState('H.265 MKV 1080p30')
const [workers, setWorkers] = useState<WorkerInfo[]>([])
@@ -44,6 +46,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
...bestehende,
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
mediaServer,
transcodeEnabled,
transcodePreset: preset,
})
@@ -94,6 +97,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
</div>
</section>
{/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */}
<section>
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Tv size={18} className="text-indigo-500" /> Dein Media-Server
</h2>
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
zusätzlich movie.nfo + poster.jpg dazu — dein Server erkennt alles ohne Nacharbeit.
</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={() => setMediaServer(o.wert)}
title={o.hinweis}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${
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-700 text-slate-400 hover:border-slate-600'
: 'bg-slate-50 border-slate-200 text-slate-600 hover:border-slate-300'
}`}
>
{o.label}
</button>
))}
</div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
</p>
</section>
{/* Verarbeitung + erkannte Hardware */}
<section>
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
+173
View File
@@ -0,0 +1,173 @@
import { useState, useEffect } from 'react'
import { X, Film, Loader2, FolderOpen, AlertCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort
// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die
// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler).
interface JobMeta {
year?: number
overview?: string
poster_path?: string
genres?: string[]
runtime?: number
type?: string
source?: string
}
interface JobDetail {
id: string
type: string
status: string
device: string
startTime: string
endTime?: string
progress: number
title?: string
error?: string
target_dir?: string
output_path?: string
meta?: JobMeta | null
}
// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs
function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path
if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}`
}
const STATUS_LABEL: Record<string, string> = {
pending: 'Wartend',
processing: 'Rippen',
transcoding: 'Komprimieren',
canceling: 'Wird abgebrochen…',
completed: 'Fertig',
failed: 'Fehler',
}
export default function JobDetailModal({ jobId, onClose }: { jobId: string | null, onClose: () => void }) {
const [detail, setDetail] = useState<JobDetail | null>(null)
const [laedt, setLaedt] = useState(false)
const { theme } = useDarkMode()
useEffect(() => {
if (!jobId) {
setDetail(null)
return
}
setLaedt(true)
api.get(`/jobs/${jobId}/detail`)
.then(r => setDetail(r.data))
.catch(() => setDetail(null))
.finally(() => setLaedt(false))
}, [jobId])
if (!jobId) return null
const meta = detail?.meta
const poster = posterUrl(meta)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}>
<div
className={`w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}
onClick={e => e.stopPropagation()}
>
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<h2 className={`text-lg font-bold truncate ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{detail?.title || 'Job-Details'}
</h2>
<button onClick={onClose} className={`p-1.5 rounded flex-shrink-0 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<X size={18} />
</button>
</div>
{laedt ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-indigo-500" size={28} />
</div>
) : !detail ? (
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Job nicht gefunden.
</p>
) : (
<div className="p-6 space-y-5">
{/* Film/Serien-Info aus der Disc-Erkennung */}
<div className="flex gap-5">
{poster ? (
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" />
) : (
<div className={`w-32 h-48 rounded-lg flex items-center justify-center flex-shrink-0 ${theme === 'dark' ? 'bg-slate-900 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
<Film size={32} />
</div>
)}
<div className="min-w-0">
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
{detail.title || 'Unbekannt'}
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null}
</p>
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
{meta?.type === 'tv' ? 'Serie' : 'Film'}
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
</p>
{meta?.genres && meta.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2">
{meta.genres.map(g => (
<span key={g} className={`text-xs px-2 py-0.5 rounded-full ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}>{g}</span>
))}
</div>
)}
{meta?.overview ? (
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
{meta.overview}
</p>
) : (
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p>
)}
</div>
</div>
{/* Nüchterne Job-Fakten */}
<div className={`rounded-lg p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-1.5 text-sm ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span>
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
</span>
{(detail.output_path || detail.target_dir) && (
<>
<span className={`flex items-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}><FolderOpen size={13} /> Ablage</span>
<span className={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
{detail.output_path || detail.target_dir}
</span>
</>
)}
</div>
{detail.error && (
<div className={`rounded-lg p-4 flex items-start gap-2.5 text-sm ${theme === 'dark' ? 'bg-rose-900/30 text-rose-300' : 'bg-rose-50 text-rose-700'}`}>
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
<span className="break-words min-w-0">{detail.error}</span>
</div>
)}
</div>
)}
</div>
</div>
)
}
+29 -13
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'
import { Folder, FolderOpen, ArrowUp, CheckCircle } from 'lucide-react'
import { Folder, FolderOpen, File, ArrowUp, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
@@ -34,6 +34,7 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
const [browsePath, setBrowsePath] = useState<string>('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [customPath, setCustomPath] = useState('')
const { theme } = useDarkMode()
@@ -58,8 +59,10 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
@@ -134,19 +137,32 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
</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.length === 0 && browseFiles.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
) : (
browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => laden(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))}
{browseFiles.map(f => (
<div key={f.name} className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
<File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span>
{f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span>
)}
</div>
))}
</>
)}
</div>
</div>
+73 -33
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, ArrowUp, FolderPlus } from 'lucide-react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
@@ -38,8 +39,10 @@ export default function StorageMounts() {
const [browsePath, setBrowsePath] = useState('/app/media')
const [browseParent, setBrowseParent] = useState<string | null>(null)
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const { theme } = useDarkMode()
const { toast } = useToast()
const browsen = async (pfad: string) => {
try {
@@ -47,8 +50,10 @@ export default function StorageMounts() {
setBrowsePath(r.data.path)
setBrowseParent(r.data.parent)
setBrowseDirs(r.data.dirs)
setBrowseFiles(r.data.files || [])
} catch {
setBrowseDirs([])
setBrowseFiles([])
}
}
@@ -64,7 +69,7 @@ export default function StorageMounts() {
}
}
const laden = async () => {
const laden = async (manuell = false) => {
try {
const [m, t] = await Promise.all([
api.get('/storage-mounts'),
@@ -72,8 +77,9 @@ export default function StorageMounts() {
])
setMounts(m.data)
setTargets(t.data)
if (manuell) toast('success', 'Speicherziele aktualisiert')
} catch {
/* API weg — Anzeige bleibt leer */
if (manuell) toast('error', 'Speicherziele konnten nicht geladen werden')
}
}
@@ -105,20 +111,25 @@ export default function StorageMounts() {
setBusy(true)
setFeedback(null)
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
const zielName = name
try {
const r = await api.post('/storage-mounts', {
name, type: typ, source: quelle,
...(username ? { username, password } : {}),
})
setFeedback(
r.data.writable
? `${name}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`
: `⚠️ „${name}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`
)
if (r.data.writable) {
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
toast('success', `${zielName}" eingehängt — Schreibtest OK`)
} else {
setFeedback(`⚠️ „${zielName}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`)
toast('error', `${zielName}" ist nur lesbar — Freigabe-Rechte prüfen`)
}
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
laden()
} catch (e: any) {
setFeedback(`${e?.response?.data?.detail || 'Einhängen fehlgeschlagen'}`)
const detail = e?.response?.data?.detail || 'Einhängen fehlgeschlagen'
setFeedback(`${detail}`)
toast('error', `${zielName}": ${detail}`)
} finally {
setBusy(false)
}
@@ -128,9 +139,12 @@ export default function StorageMounts() {
setBusy(true)
try {
await api.delete(`/storage-mounts/${mountName}`)
toast('success', `${mountName}" ausgehängt und entfernt`)
laden()
} catch (e: any) {
setFeedback(`${e?.response?.data?.detail || 'Entfernen fehlgeschlagen'}`)
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
setFeedback(`${detail}`)
toast('error', detail)
} finally {
setBusy(false)
}
@@ -144,7 +158,7 @@ export default function StorageMounts() {
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3>
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<button onClick={() => laden(true)} title="Ziele neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<RefreshCw size={16} />
</button>
</div>
@@ -202,19 +216,37 @@ export default function StorageMounts() {
</button>
</div>
<div className="max-h-36 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.length === 0 && browseFiles.length === 0 ? (
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
) : (
browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))
<>
{browseDirs.map(d => (
<button
key={d.path}
onClick={() => browsen(d.path)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm 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>
))}
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
{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>
@@ -245,13 +277,29 @@ export default function StorageMounts() {
</div>
) : (
<>
<div>
<div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
Rechner (dein PC oder NAS Name oder IP)
</label>
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
</div>
<div className="flex items-end">
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.):
Windows/NAS lehnen Gast-Abfragen fast immer mit
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der
Knopf darunter scheinbar grundlos. */}
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label>
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" />
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
</div>
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben
versucht Rippy einen Gast-Zugriff, der meist mit Zugriff verweigert" endet.
</p>
<div className="md:col-span-2">
<button
onClick={freigabenAuflisten}
disabled={!host || sharesBusy}
@@ -268,14 +316,6 @@ export default function StorageMounts() {
</select>
</div>
)}
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer (leer = Gast)</label>
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" />
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
</div>
</>
)}
</div>
+10 -3
View File
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
@@ -34,9 +35,15 @@ export default function WorkerVerwaltung() {
const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [kopiert, setKopiert] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast()
const laden = () => {
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
const laden = (manuell = false) => {
api.get('/capabilities')
.then(r => {
setWorkers(r.data.workers || [])
if (manuell) toast('success', 'Worker-Liste aktualisiert')
})
.catch(() => { if (manuell) toast('error', 'Worker-Liste konnte nicht geladen werden') })
}
useEffect(() => {
@@ -64,7 +71,7 @@ export default function WorkerVerwaltung() {
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
<button onClick={laden} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<button onClick={() => laden(true)} title="Worker neu laden" className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<RefreshCw size={16} />
</button>
</div>