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:
@@ -1,7 +1,8 @@
|
||||
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 { useDarkMode } from '../context/ThemeContext'
|
||||
import { useToast } from '../context/ToastContext'
|
||||
import StorageMounts from '../components/StorageMounts'
|
||||
import WorkerVerwaltung from '../components/WorkerVerwaltung'
|
||||
|
||||
@@ -20,6 +21,9 @@ interface SettingsState {
|
||||
transcodeEnabled: boolean
|
||||
transcodePreset: string
|
||||
keepOriginal: boolean
|
||||
mediaServer: string
|
||||
workDir: string
|
||||
makemkvAppKey: string
|
||||
}
|
||||
|
||||
const defaultSettings: SettingsState = {
|
||||
@@ -37,15 +41,29 @@ const defaultSettings: SettingsState = {
|
||||
transcodeEnabled: true,
|
||||
transcodePreset: 'H.265 MKV 1080p30',
|
||||
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 {
|
||||
name: 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> = {
|
||||
'cpu-x264': 'CPU · H.264',
|
||||
'cpu-x265': 'CPU · H.265',
|
||||
@@ -57,17 +75,36 @@ export default function SettingsPage() {
|
||||
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
|
||||
const [activeTab, setActiveTab] = useState<SettingsTab>('ripping')
|
||||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||||
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [testBusy, setTestBusy] = useState(false)
|
||||
const { theme } = useDarkMode()
|
||||
const { toast } = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/capabilities')
|
||||
.then(r => setWorkers(r.data.workers || []))
|
||||
.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(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
@@ -93,7 +130,10 @@ export default function SettingsPage() {
|
||||
try {
|
||||
await api.post('/settings', settings)
|
||||
setSaved(true)
|
||||
toast('success', 'Einstellungen gespeichert')
|
||||
setTimeout(() => setSaved(false), 3000)
|
||||
} catch {
|
||||
toast('error', 'Speichern fehlgeschlagen — läuft die API?')
|
||||
} finally {
|
||||
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={Globe} label="APIs" isActive={activeTab === 'apis'} onClick={() => setActiveTab('apis')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={activeTab === 'benachrichtigungen'} onClick={() => setActiveTab('benachrichtigungen')} darkMode={theme === 'dark'} />
|
||||
<TabButton icon={Wrench} label="System" isActive={activeTab === 'system'} onClick={() => setActiveTab('system')} darkMode={theme === 'dark'} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -186,12 +227,47 @@ export default function SettingsPage() {
|
||||
<p className={`text-sm transition-colors duration-300 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Disk nach erfolgreichem Ripping auswerfen</p>
|
||||
</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={settings.autoEject}
|
||||
onChange={(v) => handleChange('autoEject', v)}
|
||||
<Toggle
|
||||
checked={settings.autoEject}
|
||||
onChange={(v) => handleChange('autoEject', v)}
|
||||
darkMode={theme === 'dark'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Media-Server: Rippy bereitet die Ablage aufs Zielsystem vor */}
|
||||
<div className={`p-4 rounded-lg ${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}>
|
||||
<h3 className={`font-medium mb-1 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
||||
<Tv size={18} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} />
|
||||
Media-Server (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>
|
||||
</section>}
|
||||
|
||||
@@ -257,6 +333,29 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
<Toggle checked={settings.keepOriginal} onChange={(v) => handleChange('keepOriginal', v)} darkMode={theme === 'dark'} />
|
||||
</div>
|
||||
|
||||
{/* Arbeitsverzeichnis: 4K-UHD-Rohdaten (bis 100 GB) passen nicht
|
||||
auf jede VM-Platte — hier auf eine große Freigabe umlegen */}
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
Arbeitsverzeichnis für Roh-Rips (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={settings.workDir}
|
||||
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>
|
||||
</section>}
|
||||
|
||||
@@ -358,26 +457,134 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
{/* Webhook */}
|
||||
{/* Webhook — mit Anleitung und Sofort-Test (Commander-Wunsch 24.07.) */}
|
||||
{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'} />
|
||||
Webhook-Benachrichtigungen
|
||||
</h2>
|
||||
|
||||
<p className={`text-sm mb-4 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
||||
Rippy meldet sich, sobald ein Job <strong>fertig</strong> ist, <strong>fehlschlägt</strong> oder
|
||||
<strong> abgebrochen</strong> wird — direkt in deinen Chat. Der Dienst wird an der URL
|
||||
automatisch erkannt, du musst nichts weiter einstellen.
|
||||
</p>
|
||||
|
||||
{/* 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">
|
||||
<label className={`block text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
||||
Webhook URL
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.notificationWebhook}
|
||||
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
|
||||
className={`flex-1 px-3 py-2 ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'} border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all`}
|
||||
placeholder="https://discord.com/api/webhooks/… oder https://ntfy.sh/mein-thema"
|
||||
/>
|
||||
<button
|
||||
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="text"
|
||||
value={settings.notificationWebhook}
|
||||
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`}
|
||||
placeholder="https://discord.com/api/webhooks/..."
|
||||
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'}`}>
|
||||
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>
|
||||
</div>
|
||||
</section>}
|
||||
|
||||
Reference in New Issue
Block a user