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:
@@ -2,7 +2,7 @@
|
|||||||
<html lang="de">
|
<html lang="de">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Rippy — Automatisches Ripping-System</title>
|
<title>Rippy — Automatisches Ripping-System</title>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||||
|
<!-- Rippy-Favicon: Disc im Indigo-Violett-Verlauf des UI-Logos -->
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<stop offset="0" stop-color="#6366f1"/>
|
||||||
|
<stop offset="1" stop-color="#9333ea"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="2" y="2" width="60" height="60" rx="14" fill="url(#g)"/>
|
||||||
|
<circle cx="32" cy="32" r="19" fill="none" stroke="#ffffff" stroke-width="4"/>
|
||||||
|
<circle cx="32" cy="32" r="6" fill="#ffffff"/>
|
||||||
|
<path d="M32 13 A19 19 0 0 1 51 32" fill="none" stroke="#c7d2fe" stroke-width="4" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 634 B |
@@ -76,6 +76,10 @@ function App() {
|
|||||||
|
|
||||||
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen
|
||||||
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */}
|
||||||
|
|
||||||
|
<footer className="px-4 py-4 text-center text-xs text-slate-500 border-t border-slate-800">
|
||||||
|
Created with <span className="text-rose-400">❤️</span> by LucyAI, Claude and KrBrZ
|
||||||
|
</footer>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { HardDrive, Disc, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
import RipTargetModal from './RipTargetModal'
|
import RipTargetModal from './RipTargetModal'
|
||||||
import MetadataKorrektur from './MetadataKorrektur'
|
import MetadataKorrektur from './MetadataKorrektur'
|
||||||
|
|
||||||
@@ -38,21 +39,29 @@ function posterUrl(disc?: DiscInfo): string | null {
|
|||||||
export default function DeviceDiscovery() {
|
export default function DeviceDiscovery() {
|
||||||
const [devices, setDevices] = useState<Device[]>([])
|
const [devices, setDevices] = useState<Device[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
const [actionFeedback, setActionFeedback] = useState<string | null>(null)
|
||||||
const [actionBusy, setActionBusy] = useState(false)
|
const [actionBusy, setActionBusy] = useState(false)
|
||||||
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
const [modalDevice, setModalDevice] = useState<Device | null>(null)
|
||||||
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
const [korrekturDevice, setKorrekturDevice] = useState<Device | null>(null)
|
||||||
const { theme } = useDarkMode()
|
const { 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 {
|
try {
|
||||||
const response = await api.get('/devices')
|
const response = await api.get('/devices')
|
||||||
setDevices(response.data)
|
setDevices(response.data)
|
||||||
|
if (manuell) toast('success', 'Laufwerke aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Geräte:', error)
|
console.error('Fehler beim Laden der Geräte:', error)
|
||||||
|
if (manuell) toast('error', 'Laufwerke konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setTimeout(() => setRefreshing(false), 400)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +74,11 @@ export default function DeviceDiscovery() {
|
|||||||
...(targetDir ? { target_dir: targetDir } : {}),
|
...(targetDir ? { target_dir: targetDir } : {}),
|
||||||
})
|
})
|
||||||
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
setActionFeedback(`✓ Job angelegt (${response.data.id.slice(0, 8)}…) — Fortschritt im Dashboard`)
|
||||||
|
toast('success', 'Rip gestartet — Fortschritt unten bei „Neueste Jobs"')
|
||||||
} catch (error: any) {
|
} 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 {
|
} finally {
|
||||||
setActionBusy(false)
|
setActionBusy(false)
|
||||||
}
|
}
|
||||||
@@ -78,9 +90,12 @@ export default function DeviceDiscovery() {
|
|||||||
try {
|
try {
|
||||||
await api.post(`/devices/${device.id}/eject`)
|
await api.post(`/devices/${device.id}/eject`)
|
||||||
setActionFeedback('✓ Disc ausgeworfen')
|
setActionFeedback('✓ Disc ausgeworfen')
|
||||||
|
toast('success', 'Disc ausgeworfen')
|
||||||
refreshDevices()
|
refreshDevices()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setActionFeedback(`✗ ${error?.response?.data?.detail || 'Auswurf fehlgeschlagen'}`)
|
const detail = error?.response?.data?.detail || 'Auswurf fehlgeschlagen'
|
||||||
|
setActionFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
} finally {
|
} finally {
|
||||||
setActionBusy(false)
|
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>
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erkannte Geräte mit automatischer Typ-Erkennung</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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'}`}
|
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"
|
title="Geräte neu laden"
|
||||||
>
|
>
|
||||||
<RefreshCw size={20} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={20} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
|
||||||
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
|
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one,
|
||||||
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
|
// 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 }) {
|
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||||||
const [tmdbKey, setTmdbKey] = useState('')
|
const [tmdbKey, setTmdbKey] = useState('')
|
||||||
const [omdbKey, setOmdbKey] = useState('')
|
const [omdbKey, setOmdbKey] = useState('')
|
||||||
|
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||||||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||||||
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
const [preset, setPreset] = useState('H.265 MKV 1080p30')
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
@@ -44,6 +46,7 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
...bestehende,
|
...bestehende,
|
||||||
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
...(tmdbKey ? { tmdbApiKey: tmdbKey } : {}),
|
||||||
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
...(omdbKey ? { omdbApiKey: omdbKey } : {}),
|
||||||
|
mediaServer,
|
||||||
transcodeEnabled,
|
transcodeEnabled,
|
||||||
transcodePreset: preset,
|
transcodePreset: preset,
|
||||||
})
|
})
|
||||||
@@ -94,6 +97,41 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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 */}
|
{/* Verarbeitung + erkannte Hardware */}
|
||||||
<section>
|
<section>
|
||||||
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<h2 className={`flex items-center gap-2 text-lg font-semibold mb-3 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
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 [browsePath, setBrowsePath] = useState<string>('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
const [browseDirs, setBrowseDirs] = useState<BrowseDir[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [customPath, setCustomPath] = useState('')
|
const [customPath, setCustomPath] = useState('')
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
@@ -58,8 +59,10 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,10 +137,11 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-40 overflow-y-auto">
|
<div className="max-h-40 overflow-y-auto">
|
||||||
{browseDirs.length === 0 ? (
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
|
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
|
||||||
) : (
|
) : (
|
||||||
browseDirs.map(d => (
|
<>
|
||||||
|
{browseDirs.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.path}
|
key={d.path}
|
||||||
onClick={() => laden(d.path)}
|
onClick={() => laden(d.path)}
|
||||||
@@ -146,7 +150,19 @@ export default function RipTargetModal({ isOpen, onClose, onSave }: RipTargetMod
|
|||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
||||||
{d.name}
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
|
{browseFiles.map(f => (
|
||||||
|
<div key={f.name} className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
||||||
|
<File size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<span className="ml-auto text-xs flex-shrink-0">
|
||||||
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
|
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
|
||||||
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
|
// 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 [browsePath, setBrowsePath] = useState('/app/media')
|
||||||
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
const [browseParent, setBrowseParent] = useState<string | null>(null)
|
||||||
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
const [browseDirs, setBrowseDirs] = useState<{ name: string, path: string }[]>([])
|
||||||
|
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
|
||||||
const [neuerOrdner, setNeuerOrdner] = useState('')
|
const [neuerOrdner, setNeuerOrdner] = useState('')
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const browsen = async (pfad: string) => {
|
const browsen = async (pfad: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -47,8 +50,10 @@ export default function StorageMounts() {
|
|||||||
setBrowsePath(r.data.path)
|
setBrowsePath(r.data.path)
|
||||||
setBrowseParent(r.data.parent)
|
setBrowseParent(r.data.parent)
|
||||||
setBrowseDirs(r.data.dirs)
|
setBrowseDirs(r.data.dirs)
|
||||||
|
setBrowseFiles(r.data.files || [])
|
||||||
} catch {
|
} catch {
|
||||||
setBrowseDirs([])
|
setBrowseDirs([])
|
||||||
|
setBrowseFiles([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +69,7 @@ export default function StorageMounts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const laden = async () => {
|
const laden = async (manuell = false) => {
|
||||||
try {
|
try {
|
||||||
const [m, t] = await Promise.all([
|
const [m, t] = await Promise.all([
|
||||||
api.get('/storage-mounts'),
|
api.get('/storage-mounts'),
|
||||||
@@ -72,8 +77,9 @@ export default function StorageMounts() {
|
|||||||
])
|
])
|
||||||
setMounts(m.data)
|
setMounts(m.data)
|
||||||
setTargets(t.data)
|
setTargets(t.data)
|
||||||
|
if (manuell) toast('success', 'Speicherziele aktualisiert')
|
||||||
} catch {
|
} 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)
|
setBusy(true)
|
||||||
setFeedback(null)
|
setFeedback(null)
|
||||||
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
const quelle = typ === 'cifs' && host && selectedShare ? `//${host}/${selectedShare}` : source
|
||||||
|
const zielName = name
|
||||||
try {
|
try {
|
||||||
const r = await api.post('/storage-mounts', {
|
const r = await api.post('/storage-mounts', {
|
||||||
name, type: typ, source: quelle,
|
name, type: typ, source: quelle,
|
||||||
...(username ? { username, password } : {}),
|
...(username ? { username, password } : {}),
|
||||||
})
|
})
|
||||||
setFeedback(
|
if (r.data.writable) {
|
||||||
r.data.writable
|
setFeedback(`✓ „${zielName}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`)
|
||||||
? `✓ „${name}" eingehängt — Schreibtest OK, ab sofort als Rip-Ziel wählbar`
|
toast('success', `„${zielName}" eingehängt — Schreibtest OK`)
|
||||||
: `⚠️ „${name}" eingehängt, aber NUR LESBAR — Freigabe-Rechte prüfen!`
|
} 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('')
|
setName(''); setSource(''); setHost(''); setShares([]); setSelectedShare(''); setUsername(''); setPassword('')
|
||||||
laden()
|
laden()
|
||||||
} catch (e: any) {
|
} 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 {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -128,9 +139,12 @@ export default function StorageMounts() {
|
|||||||
setBusy(true)
|
setBusy(true)
|
||||||
try {
|
try {
|
||||||
await api.delete(`/storage-mounts/${mountName}`)
|
await api.delete(`/storage-mounts/${mountName}`)
|
||||||
|
toast('success', `„${mountName}" ausgehängt und entfernt`)
|
||||||
laden()
|
laden()
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setFeedback(`✗ ${e?.response?.data?.detail || 'Entfernen fehlgeschlagen'}`)
|
const detail = e?.response?.data?.detail || 'Entfernen fehlgeschlagen'
|
||||||
|
setFeedback(`✗ ${detail}`)
|
||||||
|
toast('error', detail)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
@@ -144,7 +158,7 @@ export default function StorageMounts() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<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>
|
<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} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,10 +216,11 @@ export default function StorageMounts() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-36 overflow-y-auto">
|
<div className="max-h-36 overflow-y-auto">
|
||||||
{browseDirs.length === 0 ? (
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Keine Unterordner</p>
|
<p className={`text-xs px-3 py-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Ordner ist leer</p>
|
||||||
) : (
|
) : (
|
||||||
browseDirs.map(d => (
|
<>
|
||||||
|
{browseDirs.map(d => (
|
||||||
<button
|
<button
|
||||||
key={d.path}
|
key={d.path}
|
||||||
onClick={() => browsen(d.path)}
|
onClick={() => browsen(d.path)}
|
||||||
@@ -214,7 +229,24 @@ export default function StorageMounts() {
|
|||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
||||||
{d.name}
|
{d.name}
|
||||||
</button>
|
</button>
|
||||||
))
|
))}
|
||||||
|
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
|
||||||
|
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
|
||||||
|
{browseFiles.map(f => (
|
||||||
|
<div
|
||||||
|
key={f.name}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}
|
||||||
|
>
|
||||||
|
<File size={14} className="flex-shrink-0" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<span className="ml-auto text-xs flex-shrink-0">
|
||||||
|
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,13 +277,29 @@ export default function StorageMounts() {
|
|||||||
</div>
|
</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'}`}>
|
<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)
|
Rechner (dein PC oder NAS — Name oder IP)
|
||||||
</label>
|
</label>
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={freigabenAuflisten}
|
onClick={freigabenAuflisten}
|
||||||
disabled={!host || sharesBusy}
|
disabled={!host || sharesBusy}
|
||||||
@@ -268,14 +316,6 @@ export default function StorageMounts() {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
import { Server, RefreshCw, Copy, CheckCircle } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
|
||||||
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
|
// 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 [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [kopiert, setKopiert] = useState(false)
|
const [kopiert, setKopiert] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const laden = () => {
|
const laden = (manuell = false) => {
|
||||||
api.get('/capabilities').then(r => setWorkers(r.data.workers || [])).catch(() => {})
|
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(() => {
|
useEffect(() => {
|
||||||
@@ -64,7 +71,7 @@ export default function WorkerVerwaltung() {
|
|||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3>
|
<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} />
|
<RefreshCw size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { createContext, useCallback, useContext, useRef, useState, ReactNode } from 'react'
|
||||||
|
import { CheckCircle, AlertCircle, Info } from 'lucide-react'
|
||||||
|
|
||||||
|
// Globales Feedback-System (Commander-Wunsch 24.07.): JEDE Aktualisierung im
|
||||||
|
// UI quittiert sichtbar — „Erfolgreich aktualisiert" ploppt oben rechts rein,
|
||||||
|
// statt dass man raten muss, ob der Klick etwas getan hat.
|
||||||
|
|
||||||
|
type ToastTyp = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
interface Toast {
|
||||||
|
id: number
|
||||||
|
typ: ToastTyp
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<{ toast: (typ: ToastTyp, text: string) => void }>({
|
||||||
|
toast: () => {},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
return useContext(ToastContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
const STIL: Record<ToastTyp, string> = {
|
||||||
|
success: 'bg-emerald-600 text-white',
|
||||||
|
error: 'bg-rose-600 text-white',
|
||||||
|
info: 'bg-slate-800 text-slate-100 dark:bg-slate-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON: Record<ToastTyp, ReactNode> = {
|
||||||
|
success: <CheckCircle size={18} className="flex-shrink-0" />,
|
||||||
|
error: <AlertCircle size={18} className="flex-shrink-0" />,
|
||||||
|
info: <Info size={18} className="flex-shrink-0" />,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<Toast[]>([])
|
||||||
|
const naechsteId = useRef(1)
|
||||||
|
|
||||||
|
const toast = useCallback((typ: ToastTyp, text: string) => {
|
||||||
|
const id = naechsteId.current++
|
||||||
|
setToasts(t => [...t.slice(-3), { id, typ, text }])
|
||||||
|
setTimeout(() => setToasts(t => t.filter(x => x.id !== id)), 4000)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={{ toast }}>
|
||||||
|
{children}
|
||||||
|
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 w-80 pointer-events-none">
|
||||||
|
{toasts.map(t => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`toast-rein flex items-center gap-2.5 px-4 py-3 rounded-lg shadow-lg text-sm font-medium ${STIL[t.typ]}`}
|
||||||
|
>
|
||||||
|
{ICON[t.typ]}
|
||||||
|
<span className="min-w-0">{t.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -51,3 +51,19 @@ code {
|
|||||||
background-color: var(--bg-secondary);
|
background-color: var(--bg-secondary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Toast-Einflug (ToastContext): von rechts reinploppen */
|
||||||
|
@keyframes toast-rein {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(24px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-rein {
|
||||||
|
animation: toast-rein 0.25s ease-out;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// Media-Server-Auswahl (Wizard + Einstellungen → Ripping): Rippy bereitet die
|
||||||
|
// Ablage aufs Zielsystem vor — Ordner „Titel (Jahr)", NFO + Poster, wo der
|
||||||
|
// Server sie nativ liest (Jellyfin/Emby/Kodi; Plex nutzt nur die Benennung).
|
||||||
|
export interface MediaServerOption {
|
||||||
|
wert: string
|
||||||
|
label: string
|
||||||
|
hinweis: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MEDIA_SERVER_OPTIONEN: MediaServerOption[] = [
|
||||||
|
{ wert: 'jellyfin', label: 'Jellyfin', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'emby', label: 'Emby', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'kodi', label: 'Kodi', hinweis: 'Ordner „Titel (Jahr)" + movie.nfo + poster.jpg' },
|
||||||
|
{ wert: 'plex', label: 'Plex', hinweis: 'Ordner „Titel (Jahr)" — Plex liest keine NFO' },
|
||||||
|
{ wert: 'none', label: 'Keins', hinweis: 'nur Dateien ablegen, keine Extras' },
|
||||||
|
]
|
||||||
@@ -2,12 +2,15 @@ import React from 'react'
|
|||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import { ThemeProvider } from './context/ThemeContext'
|
import { ThemeProvider } from './context/ThemeContext'
|
||||||
|
import { ToastProvider } from './context/ToastContext'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
|
<ToastProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Clock, Activity, AlertCircle, CheckCircle, Disc } from 'lucide-react'
|
|||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
import DeviceDiscovery from '../components/DeviceDiscovery'
|
import DeviceDiscovery from '../components/DeviceDiscovery'
|
||||||
|
import JobDetailModal from '../components/JobDetailModal'
|
||||||
import LiveLogSection from '../components/LiveLogSection'
|
import LiveLogSection from '../components/LiveLogSection'
|
||||||
|
|
||||||
interface Job {
|
interface Job {
|
||||||
@@ -139,6 +140,7 @@ export default function Dashboard() {
|
|||||||
const [jobs, setJobs] = useState<Job[]>([])
|
const [jobs, setJobs] = useState<Job[]>([])
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [detailJobId, setDetailJobId] = useState<string | null>(null)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -281,9 +283,13 @@ export default function Dashboard() {
|
|||||||
<TypeBadge type={job.type} />
|
<TypeBadge type={job.type} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<div className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
<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'}`}
|
||||||
|
>
|
||||||
{job.title || 'Unbekannt'}
|
{job.title || 'Unbekannt'}
|
||||||
</div>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
<td className="px-6 py-4 whitespace-nowrap">
|
||||||
<StatusBadge status={job.status} />
|
<StatusBadge status={job.status} />
|
||||||
@@ -327,6 +333,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
{/* Live-Log ganz unten — Detail-Ansicht, nicht Haupt-Info */}
|
||||||
<LiveLogSection />
|
<LiveLogSection />
|
||||||
|
|
||||||
|
{/* Klick auf einen Job-Titel: Film-/Serien-Info + Job-Fakten */}
|
||||||
|
<JobDetailModal jobId={detailJobId} onClose={() => setDetailJobId(null)} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||||||
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,18 +17,24 @@ export default function LogsPage() {
|
|||||||
const [logs, setLogs] = useState<LogEntry[]>([])
|
const [logs, setLogs] = useState<LogEntry[]>([])
|
||||||
const [filter, setFilter] = useState<string>('all')
|
const [filter, setFilter] = useState<string>('all')
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
const refreshLogs = async () => {
|
const refreshLogs = async (manuell = false) => {
|
||||||
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
|
||||||
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
|
||||||
|
if (manuell) setRefreshing(true)
|
||||||
try {
|
try {
|
||||||
const response = await api.get('/logs', { params: { limit: 200 } })
|
const response = await api.get('/logs', { params: { limit: 200 } })
|
||||||
setLogs(response.data)
|
setLogs(response.data)
|
||||||
|
if (manuell) toast('success', 'Logs aktualisiert')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fehler beim Laden der Logs:', error)
|
console.error('Fehler beim Laden der Logs:', error)
|
||||||
|
if (manuell) toast('error', 'Logs konnten nicht geladen werden')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
if (manuell) setRefreshing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,12 +164,32 @@ export default function LogsPage() {
|
|||||||
>
|
>
|
||||||
Erfolg
|
Erfolg
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('warning')}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
filter === 'warning'
|
||||||
|
? 'bg-amber-600 text-white'
|
||||||
|
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Warnung
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('error')}
|
||||||
|
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
filter === 'error'
|
||||||
|
? 'bg-rose-600 text-white'
|
||||||
|
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Fehler
|
||||||
|
</button>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
<button
|
<button
|
||||||
onClick={refreshLogs}
|
onClick={() => refreshLogs(true)}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'}`}
|
||||||
>
|
>
|
||||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
<RefreshCw size={14} className={loading || refreshing ? 'animate-spin' : ''} />
|
||||||
Neu laden
|
Neu laden
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive } from 'lucide-react'
|
import { Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle, HardDrive, Wrench, Send, Tv } from 'lucide-react'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { useDarkMode } from '../context/ThemeContext'
|
import { useDarkMode } from '../context/ThemeContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
import StorageMounts from '../components/StorageMounts'
|
import StorageMounts from '../components/StorageMounts'
|
||||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||||
|
|
||||||
@@ -20,6 +21,9 @@ interface SettingsState {
|
|||||||
transcodeEnabled: boolean
|
transcodeEnabled: boolean
|
||||||
transcodePreset: string
|
transcodePreset: string
|
||||||
keepOriginal: boolean
|
keepOriginal: boolean
|
||||||
|
mediaServer: string
|
||||||
|
workDir: string
|
||||||
|
makemkvAppKey: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: SettingsState = {
|
const defaultSettings: SettingsState = {
|
||||||
@@ -37,15 +41,29 @@ const defaultSettings: SettingsState = {
|
|||||||
transcodeEnabled: true,
|
transcodeEnabled: true,
|
||||||
transcodePreset: 'H.265 MKV 1080p30',
|
transcodePreset: 'H.265 MKV 1080p30',
|
||||||
keepOriginal: false,
|
keepOriginal: false,
|
||||||
|
mediaServer: 'none',
|
||||||
|
workDir: '',
|
||||||
|
makemkvAppKey: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen'
|
type SettingsTab = 'ripping' | 'verarbeitung' | 'worker' | 'speicherziele' | 'apis' | 'benachrichtigungen' | 'system'
|
||||||
|
|
||||||
interface WorkerInfo {
|
interface WorkerInfo {
|
||||||
name: string
|
name: string
|
||||||
encoders: string[]
|
encoders: string[]
|
||||||
|
info?: { makemkv?: string, handbrake?: string, makemkv_key?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SystemInfo {
|
||||||
|
api_version: string
|
||||||
|
plaetze: { name: string, frei_gb: number, gesamt_gb: number }[]
|
||||||
|
workers: WorkerInfo[]
|
||||||
|
makemkv_key_ui: boolean
|
||||||
|
webhook_gesetzt: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||||||
|
|
||||||
const ENCODER_LABELS: Record<string, string> = {
|
const ENCODER_LABELS: Record<string, string> = {
|
||||||
'cpu-x264': 'CPU · H.264',
|
'cpu-x264': 'CPU · H.264',
|
||||||
'cpu-x265': 'CPU · H.265',
|
'cpu-x265': 'CPU · H.265',
|
||||||
@@ -57,17 +75,36 @@ export default function SettingsPage() {
|
|||||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||||
|
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
|
const [testBusy, setTestBusy] = useState(false)
|
||||||
const { theme } = useDarkMode()
|
const { theme } = useDarkMode()
|
||||||
|
const { toast } = useToast()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get('/capabilities')
|
api.get('/capabilities')
|
||||||
.then(r => setWorkers(r.data.workers || []))
|
.then(r => setWorkers(r.data.workers || []))
|
||||||
.catch(() => setWorkers([]))
|
.catch(() => setWorkers([]))
|
||||||
|
api.get('/system/info')
|
||||||
|
.then(r => setSystemInfo(r.data))
|
||||||
|
.catch(() => setSystemInfo(null))
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const webhookTesten = async () => {
|
||||||
|
if (!settings.notificationWebhook) return
|
||||||
|
setTestBusy(true)
|
||||||
|
try {
|
||||||
|
await api.post('/notifications/test', { url: settings.notificationWebhook })
|
||||||
|
toast('success', 'Test-Nachricht gesendet — prüfe deinen Kanal!')
|
||||||
|
} catch (e: any) {
|
||||||
|
toast('error', e?.response?.data?.detail || 'Test fehlgeschlagen')
|
||||||
|
} finally {
|
||||||
|
setTestBusy(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadSettings = async () => {
|
const loadSettings = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -93,7 +130,10 @@ export default function SettingsPage() {
|
|||||||
try {
|
try {
|
||||||
await api.post('/settings', settings)
|
await api.post('/settings', settings)
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
|
toast('success', 'Einstellungen gespeichert')
|
||||||
setTimeout(() => setSaved(false), 3000)
|
setTimeout(() => setSaved(false), 3000)
|
||||||
|
} catch {
|
||||||
|
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
@@ -130,6 +170,7 @@ export default function SettingsPage() {
|
|||||||
<TabButton icon={HardDrive} label="Speicherziele" isActive={activeTab === 'speicherziele'} onClick={() => setActiveTab('speicherziele')} 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={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={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'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
@@ -192,6 +233,41 @@ export default function SettingsPage() {
|
|||||||
darkMode={theme === 'dark'}
|
darkMode={theme === 'dark'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 (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>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||||
|
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||||||
|
<button
|
||||||
|
key={o.wert}
|
||||||
|
onClick={() => handleChange('mediaServer', o.wert)}
|
||||||
|
title={o.hinweis}
|
||||||
|
className={`px-3 py-2 rounded-lg 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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{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 === settings.mediaServer)?.hinweis}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
@@ -257,6 +333,29 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
|
||||||
|
auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
|
Arbeitsverzeichnis für Roh-Rips (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={settings.workDir}
|
||||||
|
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.
|
||||||
|
{systemInfo?.plaetze?.length ? (
|
||||||
|
<> Aktuell frei: {systemInfo.plaetze.map(p => `${p.name}: ${p.frei_gb} GB`).join(' · ')}</>
|
||||||
|
) : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
@@ -358,26 +457,134 @@ export default function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|
||||||
{/* Webhook */}
|
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
||||||
{activeTab === 'benachrichtigungen' && <section>
|
{activeTab === 'benachrichtigungen' && <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'}`}>
|
<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'} />
|
<AlertCircle size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||||
Webhook-Benachrichtigungen
|
Webhook-Benachrichtigungen
|
||||||
</h2>
|
</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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 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'}`}>
|
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||||
Webhook URL
|
Webhook URL
|
||||||
</label>
|
</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={settings.notificationWebhook}
|
value={settings.notificationWebhook}
|
||||||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
onChange={(e) => handleChange('notificationWebhook', 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`}
|
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/..."
|
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
</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>}
|
||||||
|
|
||||||
|
{/* 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 & 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>
|
||||||
|
{!systemInfo || systemInfo.workers.length === 0 ? (
|
||||||
|
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : '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>
|
||||||
|
{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'}`}>
|
||||||
|
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'}`}>
|
||||||
|
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
|
||||||
|
type="password"
|
||||||
|
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")'}
|
||||||
/>
|
/>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
<p className={`text-xs ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||||
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>}
|
</section>}
|
||||||
|
|||||||
Reference in New Issue
Block a user