refactor(ui): Modals & Unterseiten auf Primitives & dark: umgestellt

This commit is contained in:
Hitonabi
2026-07-24 16:04:46 +02:00
parent 7004d798d6
commit 90b708302e
10 changed files with 808 additions and 869 deletions
+36 -28
View File
@@ -8,8 +8,6 @@ import SettingsPage from './pages/Settings'
import LogsPage from './pages/Logs' import LogsPage from './pages/Logs'
import FirstRunWizard from './components/FirstRunWizard' import FirstRunWizard from './components/FirstRunWizard'
// Metadaten-Seite entfernt (24.07., Abnahme durch den Commander): die
// Korrektur läuft komplett über das „Nicht korrekt?"-Popup am Dashboard.
type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs' type Page = 'dashboard' | 'anleitung' | 'settings' | 'logs'
function App() { function App() {
@@ -35,58 +33,68 @@ function App() {
] ]
return ( return (
<div className="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100 font-sans transition-colors duration-300"> <div className="min-h-screen bg-slate-50 text-slate-900 dark:bg-slate-950 dark:text-slate-100 font-sans">
<div className="flex h-screen overflow-hidden"> <div className="flex h-screen overflow-hidden">
{/* Sidebar */} {/* Cinema Sidebar */}
<aside className="w-64 bg-slate-900 text-slate-100 flex flex-col"> <aside className="w-64 bg-slate-900 dark:bg-slate-950 text-slate-100 flex flex-col border-r border-slate-800/80 shadow-2xl z-20">
<div className="p-6"> <div className="p-6">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl"> <div className="p-2.5 bg-gradient-to-br from-amber-500 via-indigo-600 to-purple-600 rounded-xl shadow-lg shadow-amber-500/20">
<Disc size={28} className="text-white" /> <Disc size={26} className="text-white animate-pulse" />
</div> </div>
<div> <div>
<h1 className="text-xl font-bold tracking-tight">Rippy</h1> <h1 className="text-xl font-bold tracking-tight bg-gradient-to-r from-white via-slate-100 to-amber-300 bg-clip-text text-transparent">
<p className="text-xs text-slate-400 dark:text-slate-500">Automated Ripping</p> Rippy
</h1>
<p className="text-xs text-amber-400/80 font-medium tracking-wide uppercase">Cinema Edition</p>
</div> </div>
</div> </div>
</div> </div>
<div className="px-3 mb-2">
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="mx-3 flex items-center justify-center gap-2 px-3 py-2 rounded-lg bg-slate-800/50 text-slate-400 hover:bg-slate-700 hover:text-white transition-all duration-200" className="w-full flex items-center justify-between px-3 py-2 rounded-xl bg-slate-800/50 hover:bg-slate-800 text-slate-400 hover:text-slate-200 border border-slate-800 transition-all duration-200 text-xs font-medium"
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'} title={theme === 'dark' ? 'Light Mode aktivieren' : 'Dark Mode aktivieren'}
> >
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />} <span className="flex items-center gap-2">
{theme === 'dark' ? <Moon size={15} className="text-amber-400" /> : <Sun size={15} className="text-amber-500" />}
<span>{theme === 'dark' ? 'Dark Mode' : 'Light Mode'}</span>
</span>
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-slate-700/60 text-slate-300">
{theme}
</span>
</button> </button>
</div>
<nav className="flex-1 px-3 py-2 space-y-1"> <nav className="flex-1 px-3 py-2 space-y-1.5">
{navItems.map((item) => ( {navItems.map((item) => {
const isActive = page === item.id
return (
<button <button
key={item.id} key={item.id}
onClick={() => setPage(item.id as Page)} onClick={() => setPage(item.id as Page)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${ className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
page === item.id isActive
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30' ? 'bg-gradient-to-r from-amber-500/20 via-slate-800 to-slate-800/80 text-amber-300 border-l-4 border-amber-500 shadow-md shadow-amber-500/10'
: 'text-slate-400 hover:bg-slate-800 hover:text-white' : 'text-slate-400 hover:bg-slate-800/50 hover:text-slate-200 border-l-4 border-transparent'
}`} }`}
> >
<item.icon size={20} /> <item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} />
<span className="font-medium">{item.label}</span> <span>{item.label}</span>
</button> </button>
))} )
})}
</nav> </nav>
{/* Abmelden-Knopf entfernt (23.07.): es gibt (noch) keinen <footer className="px-4 py-4 text-center text-[11px] text-slate-500 border-t border-slate-800/80">
Login-Flow im UI — ein toter Knopf gaukelt nur einen vor. */} Created with <span className="text-rose-400"></span> by LucyAI, Claude & KrBrZ
<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> </footer>
</aside> </aside>
{/* Main Content */} {/* Main Content Area */}
<main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950"> <main className="flex-1 overflow-auto bg-slate-50 dark:bg-slate-950">
<div className="max-w-7xl mx-auto p-6"> <div className="max-w-7xl mx-auto p-6 md:p-8">
{page === 'dashboard' && <Dashboard />} {page === 'dashboard' && <Dashboard />}
{page === 'anleitung' && <AnleitungPage />} {page === 'anleitung' && <AnleitungPage />}
{page === 'logs' && <LogsPage />} {page === 'logs' && <LogsPage />}
+28 -38
View File
@@ -1,5 +1,6 @@
import { useState } from 'react' import { AlertTriangle, CheckCircle2, HelpCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
interface ConfirmDialogProps { interface ConfirmDialogProps {
isOpen: boolean isOpen: boolean
@@ -22,62 +23,51 @@ export default function ConfirmDialog({
cancelText = 'Abbrechen', cancelText = 'Abbrechen',
type = 'info', type = 'info',
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const { theme } = useDarkMode()
const getIcon = () => { const getIcon = () => {
switch (type) { switch (type) {
case 'warning': return <span className="text-4xl"></span> case 'warning':
case 'success': return <span className="text-4xl"></span> return <AlertTriangle className="text-amber-500 w-8 h-8 flex-shrink-0" />
default: return <span className="text-4xl">🤔</span> case 'success':
return <CheckCircle2 className="text-emerald-500 w-8 h-8 flex-shrink-0" />
default:
return <HelpCircle className="text-indigo-500 w-8 h-8 flex-shrink-0" />
} }
} }
const getConfirmColor = () => { const getConfirmVariant = () => {
switch (type) { switch (type) {
case 'warning': return 'bg-amber-600 hover:bg-amber-700 shadow-lg shadow-amber-500/30' case 'warning':
case 'success': return 'bg-emerald-600 hover:bg-emerald-700 shadow-lg shadow-emerald-500/30' return 'amber'
default: return 'bg-indigo-600 hover:bg-indigo-700 shadow-lg shadow-indigo-500/30' case 'success':
return 'primary'
default:
return 'primary'
} }
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm transition-all"> <Modal isOpen={isOpen} onClose={onClose} maxWidth="md">
<div className={`w-full max-w-md rounded-xl shadow-2xl transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> <div className="flex items-start gap-4">
{/* Header */}
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<div className="flex items-center gap-3">
{getIcon()} {getIcon()}
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="space-y-2 flex-1">
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
{title} {title}
</h2> </h2>
</div> <p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
</div>
{/* Body */}
<div className="px-6 py-6">
<p className={`text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
{message} {message}
</p> </p>
</div> </div>
</div>
{/* Footer */} <div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
<div className={`px-6 py-4 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex justify-end gap-3`}> <Button variant="secondary" onClick={onClose}>
<button
onClick={onClose}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${theme === 'dark' ? 'text-slate-400 hover:bg-slate-700' : 'text-slate-600 hover:bg-slate-100'}`}
>
{cancelText} {cancelText}
</button> </Button>
<button <Button variant={getConfirmVariant()} onClick={onConfirm}>
onClick={onConfirm}
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
>
{confirmText} {confirmText}
</button> </Button>
</div>
</div>
</div> </div>
</Modal>
) )
} }
+53 -57
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Disc, Cpu, Globe, Tv, 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 { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer' import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
import { Button } from './ui/Button'
// Erster Start: Rippy universell einrichten (Commander-Ziel: All-in-one, import { Input, Select } from './ui/Input'
// weitergebbar — keine Handarbeit in .env-Dateien nötig).
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -29,7 +27,6 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
const [workers, setWorkers] = useState<WorkerInfo[]>([]) const [workers, setWorkers] = useState<WorkerInfo[]>([])
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
api.get('/capabilities') api.get('/capabilities')
@@ -59,14 +56,12 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
} }
} }
const feldKlasse = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
return ( return (
<div className={`min-h-screen flex items-center justify-center p-6 ${theme === 'dark' ? 'bg-slate-950' : 'bg-slate-100'}`}> <div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
<div className={`w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden ${theme === 'dark' ? 'bg-slate-900 border border-slate-800' : 'bg-white'}`}> <div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
<div className="bg-gradient-to-r from-indigo-600 to-purple-700 p-8 text-white"> <div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<Disc size={32} /> <Disc size={32} className="animate-disc-spin" />
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1> <h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
</div> </div>
<p className="opacity-90">Einmalige Einrichtung dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p> <p className="opacity-90">Einmalige Einrichtung dauert keine zwei Minuten. Alles ist später unter Einstellungen änderbar.</p>
@@ -75,36 +70,38 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
<div className="p-8 space-y-8"> <div className="p-8 space-y-8">
{/* Metadaten-APIs */} {/* Metadaten-APIs */}
<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 text-slate-900 dark:text-slate-100">
<Globe size={18} className="text-indigo-500" /> Metadaten-Erkennung <Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
<div> <Input
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> type="password"
TMDB API-Key <span className="text-indigo-500">(empfohlen)</span> label="TMDB API-Key (empfohlen)"
</label> value={tmdbKey}
<input type="password" value={tmdbKey} onChange={e => setTmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf themoviedb.org" /> onChange={e => setTmdbKey(e.target.value)}
</div> placeholder="Kostenlos auf themoviedb.org"
<div> />
<label className={`block text-sm font-medium mb-1 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <Input
OMDb API-Key <span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>(optional, zweite Quelle)</span> type="password"
</label> label="OMDb API-Key (optional, zweite Quelle)"
<input type="password" value={omdbKey} onChange={e => setOmdbKey(e.target.value)} className={feldKlasse} placeholder="Kostenlos auf omdbapi.com" /> value={omdbKey}
</div> onChange={e => setOmdbKey(e.target.value)}
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> placeholder="Kostenlos auf omdbapi.com"
/>
<p className="text-xs text-slate-500 dark:text-slate-400">
Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label. Ohne Keys rippt Rippy trotzdem Discs heißen dann nur wie ihr Volume-Label.
</p> </p>
</div> </div>
</section> </section>
{/* Media-Server: worauf soll Rippy die Ablage vorbereiten? */} {/* Media-Server */}
<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 text-slate-900 dark:text-slate-100">
<Tv size={18} className="text-indigo-500" /> Dein Media-Server <Tv size={18} className="text-amber-500" /> Dein Media-Server
</h2> </h2>
<p className={`text-sm mb-3 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
Rippy benennt fertige Rips als Titel (Jahr)" und legt für Jellyfin/Emby/Kodi 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. zusätzlich movie.nfo + poster.jpg dazu.
</p> </p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2"> <div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{MEDIA_SERVER_OPTIONEN.map(o => ( {MEDIA_SERVER_OPTIONEN.map(o => (
@@ -113,76 +110,75 @@ export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
type="button" type="button"
onClick={() => setMediaServer(o.wert)} onClick={() => setMediaServer(o.wert)}
title={o.hinweis} title={o.hinweis}
className={`px-3 py-2 rounded-lg text-sm font-medium border transition-all ${ className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
mediaServer === o.wert mediaServer === o.wert
? theme === 'dark' ? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
: '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} {o.label}
</button> </button>
))} ))}
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis} {MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
</p> </p>
</section> </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 text-slate-900 dark:text-slate-100">
<Cpu size={18} className="text-indigo-500" /> Verarbeitung <Cpu size={18} className="text-amber-500" /> Verarbeitung
</h2> </h2>
<div className={`rounded-lg p-4 mb-3 ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> <div className="rounded-xl p-4 mb-3 bg-slate-50 dark:bg-slate-950/80 border border-slate-200 dark:border-slate-800">
<p className={`text-sm font-medium mb-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>Erkannte Encoder in deinem System:</p> <p className="text-sm font-medium mb-2 text-slate-700 dark:text-slate-300">Erkannte Encoder in deinem System:</p>
{workers.length === 0 ? ( {workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet — startet gerade?</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet — startet gerade?</p>
) : ( ) : (
workers.map(w => ( workers.map(w => (
<div key={w.name} className="mb-1"> <div key={w.name} className="mb-1">
<span className={`text-xs font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>{w.name}: </span> <span className="text-xs font-mono text-slate-500 dark:text-slate-400">{w.name}: </span>
{w.encoders.map(e => ( {w.encoders.map(e => (
<span key={e} className={`inline-block text-xs px-2 py-0.5 rounded mr-1 ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> <span key={e} className={`inline-block text-xs px-2 py-0.5 rounded font-medium mr-1 ${
e.startsWith('cpu')
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
}`}>
{ENCODER_LABELS[e] || e} {ENCODER_LABELS[e] || e}
</span> </span>
))} ))}
</div> </div>
)) ))
)} )}
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Tipp: GPU-Maschinen im Netz können als optionale Transcode-Worker angebunden werden (siehe README).
</p>
</div> </div>
<label className={`flex items-center gap-3 mb-3 text-sm ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <label className="flex items-center gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
<input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-indigo-600" /> <input type="checkbox" checked={transcodeEnabled} onChange={e => setTranscodeEnabled(e.target.checked)} className="w-4 h-4 accent-amber-500 rounded" />
Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray) Nach dem Rip auf Arbeitsgröße komprimieren (empfohlen — sonst ~40 GB pro Blu-ray)
</label> </label>
{transcodeEnabled && ( {transcodeEnabled && (
<select value={preset} onChange={e => setPreset(e.target.value)} className={feldKlasse}> <Select value={preset} onChange={e => setPreset(e.target.value)}>
<option value="H.265 MKV 1080p30">H.265 — klein &amp; modern (Standard)</option> <option value="H.265 MKV 1080p30">H.265 — klein &amp; modern (Standard)</option>
<option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option> <option value="HQ 1080p30 Surround">H.264 — schneller auf schwacher CPU, etwas größer</option>
</select> </Select>
)} )}
</section> </section>
{error && <p className="text-sm text-rose-500">{error}</p>} {error && <p className="text-sm text-rose-500">{error}</p>}
<button <Button
variant="amber"
size="lg"
onClick={abschliessen} onClick={abschliessen}
disabled={saving} disabled={saving}
className="w-full flex items-center justify-center gap-2 px-6 py-3 bg-indigo-600 text-white rounded-lg font-medium hover:bg-indigo-700 transition-colors disabled:opacity-50" className="w-full"
> >
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>} {saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
</button> </Button>
<p className={`text-xs text-center flex items-center justify-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
<CheckCircle size={12} /> Läuft komplett lokal keine Cloud, keine Telemetrie. <CheckCircle size={12} /> Läuft komplett lokal keine Cloud, keine Telemetrie.
</p> </p>
</div> </div>
+55 -75
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { X, Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react' import { Film, Loader2, FolderOpen, AlertCircle, Download } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
// Klick auf den Titel in „Neueste Jobs" (Commander-Wunsch 24.07.): sofort import { StatusBadge } from './ui/Badge'
// sehen, WAS da gerippt wurde — Poster, Jahr, Beschreibung — plus die
// nüchternen Job-Fakten (Ziel, Ausgabepfad, Fehler).
interface JobMeta { interface JobMeta {
year?: number year?: number
@@ -32,22 +30,12 @@ interface JobDetail {
meta?: JobMeta | null meta?: JobMeta | null
} }
// TMDB liefert relative Poster-Pfade, OMDb/Jikan absolute URLs
function posterUrl(meta?: JobMeta | null): string | null { function posterUrl(meta?: JobMeta | null): string | null {
const p = meta?.poster_path const p = meta?.poster_path
if (!p) return null if (!p) return null
return p.startsWith('http') ? p : `https://image.tmdb.org/t/p/w342${p}` 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',
}
interface JobDatei { interface JobDatei {
name: string name: string
size_mb: number | null size_mb: number | null
@@ -57,7 +45,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
const [detail, setDetail] = useState<JobDetail | null>(null) const [detail, setDetail] = useState<JobDetail | null>(null)
const [dateien, setDateien] = useState<JobDatei[] | null>(null) const [dateien, setDateien] = useState<JobDatei[] | null>(null)
const [laedt, setLaedt] = useState(false) const [laedt, setLaedt] = useState(false)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
if (!jobId) { if (!jobId) {
@@ -69,7 +56,6 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
api.get(`/jobs/${jobId}/detail`) api.get(`/jobs/${jobId}/detail`)
.then(r => { .then(r => {
setDetail(r.data) setDetail(r.data)
// Fertige Jobs: Dateiliste für die Download-Knöpfe nachladen
if (r.data?.status === 'completed') { if (r.data?.status === 'completed') {
api.get(`/jobs/${jobId}/files`) api.get(`/jobs/${jobId}/files`)
.then(f => setDateien(f.data.files)) .then(f => setDateien(f.data.files))
@@ -86,45 +72,40 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
const poster = posterUrl(meta) const poster = posterUrl(meta)
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm" onClick={onClose}> <Modal
<div isOpen={!!jobId}
className={`w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`} onClose={onClose}
onClick={e => e.stopPropagation()} title={detail?.title || 'Job-Details'}
maxWidth="2xl"
> >
<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 ? ( {laedt ? (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="animate-spin text-indigo-500" size={28} /> <Loader2 className="animate-spin text-amber-500" size={28} />
</div> </div>
) : !detail ? ( ) : !detail ? (
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
Job nicht gefunden. Job nicht gefunden.
</p> </p>
) : ( ) : (
<div className="p-6 space-y-5"> <div className="space-y-6">
{/* Film/Serien-Info aus der Disc-Erkennung */} {/* Film/Serien-Info aus der Disc-Erkennung */}
<div className="flex gap-5"> <div className="flex gap-5">
{poster ? ( {poster ? (
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" /> <img src={poster} alt={detail.title || ''} className="w-32 rounded-xl object-cover flex-shrink-0 shadow-lg shadow-black/30 border border-slate-700/50" />
) : ( ) : (
<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'}`}> <div className="w-32 h-48 rounded-xl flex items-center justify-center flex-shrink-0 bg-slate-100 dark:bg-slate-800 text-slate-400 dark:text-slate-500 border border-slate-200 dark:border-slate-700">
<Film size={32} /> <Film size={36} />
</div> </div>
)} )}
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <div className="flex items-center gap-2 flex-wrap">
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
{detail.title || 'Unbekannt'} {detail.title || 'Unbekannt'}
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null} {meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
</p> </h3>
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <StatusBadge status={detail.status} />
</div>
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
{meta?.type === 'tv' ? 'Serie' : 'Film'} {meta?.type === 'tv' ? 'Serie' : 'Film'}
{meta?.source ? ` · Quelle: ${meta.source}` : ''} {meta?.source ? ` · Quelle: ${meta.source}` : ''}
{meta?.runtime ? ` · ${meta.runtime} min` : ''} {meta?.runtime ? ` · ${meta.runtime} min` : ''}
@@ -132,61 +113,62 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
{meta?.genres && meta.genres.length > 0 && ( {meta?.genres && meta.genres.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-2"> <div className="flex flex-wrap gap-1.5 mt-2">
{meta.genres.map(g => ( {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> <span key={g} className="text-xs px-2.5 py-0.5 rounded-full bg-slate-100 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-200 dark:border-slate-700">
{g}
</span>
))} ))}
</div> </div>
)} )}
{meta?.overview ? ( {meta?.overview ? (
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> <p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
{meta.overview} {meta.overview}
</p> </p>
) : ( ) : (
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-sm mt-3 italic text-slate-400 dark:text-slate-500">
Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt. Keine Metadaten zu diesem Job gespeichert die Disc wurde ohne Erkennung gerippt.
</p> </p>
)} )}
</div> </div>
</div> </div>
{/* Nüchterne Job-Fakten */} {/* Job-Fakten Grid */}
<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'}`}> <div className="rounded-xl p-4 grid grid-cols-[auto_1fr] gap-x-6 gap-y-2 text-sm bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''} {detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
</span> </span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span> <span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'} {detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
</span> </span>
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span> <span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}> <span className="text-slate-900 dark:text-slate-200">
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'} {detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
</span> </span>
{(detail.output_path || detail.target_dir) && ( {(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="flex items-center gap-1 text-slate-500 dark:text-slate-400 font-medium"><FolderOpen size={13} /> Ablage</span>
<span className={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}> <span className="font-mono text-xs self-center break-all text-slate-900 dark:text-slate-200">
{detail.output_path || detail.target_dir} {detail.output_path || detail.target_dir}
</span> </span>
</> </>
)} )}
</div> </div>
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */} {/* Dateiliste & Download */}
{detail.status === 'completed' && dateien && dateien.length > 0 && ( {detail.status === 'completed' && dateien && dateien.length > 0 && (
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}> <div className="rounded-xl p-4 bg-slate-50 dark:bg-slate-950/80 border border-slate-200/80 dark:border-slate-800">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-3">
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}> <p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
<Download size={15} /> Dateien herunterladen <Download size={15} className="text-amber-500" /> Dateien herunterladen
</p> </p>
{dateien.length > 1 && ( {dateien.length > 1 && (
<button <Button
variant="amber"
size="sm"
onClick={() => { onClick={() => {
// Gestaffelte Einzel-Downloads statt Server-seitigem
// 40-GB-Zip — der Browser fragt ggf. einmal nach
// Erlaubnis für mehrere Downloads.
dateien.forEach((f, i) => { dateien.forEach((f, i) => {
setTimeout(() => { setTimeout(() => {
const a = document.createElement('a') const a = document.createElement('a')
@@ -198,24 +180,23 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
}, i * 400) }, i * 400)
}) })
}} }}
className="text-xs font-medium px-3 py-1.5 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors"
> >
Alle herunterladen ({dateien.length}) Alle herunterladen ({dateien.length})
</button> </Button>
)} )}
</div> </div>
<div className="space-y-1"> <div className="space-y-1.5">
{dateien.map(f => ( {dateien.map(f => (
<a <a
key={f.name} key={f.name}
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`} href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
download download
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${theme === 'dark' ? 'text-indigo-300 hover:bg-slate-800' : 'text-indigo-700 hover:bg-slate-100'}`} className="flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors text-amber-600 dark:text-amber-300 hover:bg-slate-100 dark:hover:bg-slate-800"
> >
<Download size={14} className="flex-shrink-0" /> <Download size={14} className="flex-shrink-0" />
<span className="truncate min-w-0">{f.name}</span> <span className="truncate min-w-0">{f.name}</span>
{f.size_mb != null && ( {f.size_mb != null && (
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className="ml-auto text-xs flex-shrink-0 text-slate-500 dark:text-slate-400 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span> </span>
)} )}
@@ -226,14 +207,13 @@ export default function JobDetailModal({ jobId, onClose }: { jobId: string | nul
)} )}
{detail.error && ( {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'}`}> <div className="rounded-xl p-4 flex items-start gap-2.5 text-sm bg-rose-500/10 border border-rose-500/20 text-rose-600 dark:text-rose-400">
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" /> <AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
<span className="break-words min-w-0">{detail.error}</span> <span className="break-words min-w-0">{detail.error}</span>
</div> </div>
)} )}
</div> </div>
)} )}
</div> </Modal>
</div>
) )
} }
+29 -34
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react' import { Terminal, Play, CheckCircle, AlertCircle } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Card, CardHeader, CardTitle } from './ui/Card'
interface LiveLogEntry { interface LiveLogEntry {
id: string id: string
@@ -14,7 +14,6 @@ interface LiveLogEntry {
export default function LiveLogSection() { export default function LiveLogSection() {
const [logs, setLogs] = useState<LiveLogEntry[]>([]) const [logs, setLogs] = useState<LiveLogEntry[]>([])
const [currentJob, setCurrentJob] = useState<any>(null) const [currentJob, setCurrentJob] = useState<any>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
const fetchJobs = async () => { const fetchJobs = async () => {
@@ -50,28 +49,28 @@ export default function LiveLogSection() {
const getStatusColor = (level: string) => { const getStatusColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return 'text-rose-500' case 'error': return 'text-rose-500 dark:text-rose-400'
case 'warning': return 'text-amber-500' case 'warning': return 'text-amber-500 dark:text-amber-400'
case 'success': return 'text-emerald-500' case 'success': return 'text-emerald-500 dark:text-emerald-400'
default: return 'text-blue-500' default: return 'text-sky-500 dark:text-sky-400'
} }
} }
const getStatusIcon = (level: string) => { const getStatusIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
return ( return (
<div className={`rounded-xl shadow-sm border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} flex items-center justify-between`}> <CardHeader>
<div> <div>
<h2 className={`text-lg font-semibold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Live-Log</h2> <CardTitle>Live-Log</CardTitle>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <p className="text-sm text-slate-500 dark:text-slate-400">
{currentJob {currentJob
? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)` ? `Aktiver Job: ${currentJob.type.toUpperCase()} (${currentJob.progress}%)`
: 'Kein aktiver Job'} : 'Kein aktiver Job'}
@@ -79,21 +78,19 @@ export default function LiveLogSection() {
</div> </div>
{currentJob && ( {currentJob && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 ${ <div className={`px-3 py-1 rounded-full text-xs font-medium flex items-center gap-2 border ${
currentJob.progress === 100 currentJob.progress === 100
? theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400' : 'bg-emerald-100 text-emerald-700' ? 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
: theme === 'dark' ? 'bg-blue-900/30 text-blue-400' : 'bg-blue-100 text-blue-700' : 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
}`}> }`}>
{currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />} {currentJob.progress === 100 ? <CheckCircle size={12} /> : <Play size={12} />}
{currentJob.progress}% {currentJob.progress}%
</div> </div>
</div> </div>
)} )}
</div> </CardHeader>
<div className={`max-h-[400px] overflow-y-auto p-4 space-y-2 transition-colors duration-300 font-mono text-xs ${ <div className="max-h-[380px] overflow-y-auto p-4 space-y-2 font-mono text-xs bg-slate-900/90 text-slate-200 border-b border-slate-800">
theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'
}`}>
{logs.length === 0 ? ( {logs.length === 0 ? (
<div className="text-center py-8 text-slate-500"> <div className="text-center py-8 text-slate-500">
<Terminal size={32} className="mx-auto mb-2 opacity-50" /> <Terminal size={32} className="mx-auto mb-2 opacity-50" />
@@ -101,14 +98,14 @@ export default function LiveLogSection() {
</div> </div>
) : ( ) : (
logs.map((log) => ( logs.map((log) => (
<div key={log.id} className={`flex items-start gap-2 ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <div key={log.id} className="flex items-start gap-2 text-slate-300">
<span className="text-slate-500 whitespace-nowrap"> <span className="text-slate-500 whitespace-nowrap">
{new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })} {new Date(log.timestamp).toLocaleTimeString('de-DE', { hour12: false })}
</span> </span>
<span className={`font-bold ${getStatusColor(log.level)}`}> <span className={`font-bold ${getStatusColor(log.level)}`}>
[{log.source}] [{log.source}]
</span> </span>
<span className="flex-1">{log.message}</span> <span className="flex-1 text-slate-200">{log.message}</span>
<span className={getStatusColor(log.level)}> <span className={getStatusColor(log.level)}>
{getStatusIcon(log.level)} {getStatusIcon(log.level)}
</span> </span>
@@ -118,30 +115,28 @@ export default function LiveLogSection() {
</div> </div>
{/* Quick Stats */} {/* Quick Stats */}
<div className={`px-6 py-3 border-t transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'} grid grid-cols-3 gap-4`}>
{currentJob && ( {currentJob && (
<> <div className="px-6 py-3 grid grid-cols-3 gap-4 bg-slate-50/50 dark:bg-slate-900/50">
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p> <p className="text-xs text-slate-500 dark:text-slate-400">Fortschritt</p>
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-lg font-bold text-slate-900 dark:text-slate-100">
{currentJob.progress}% {currentJob.progress}%
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p> <p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.device || 'Unbekannt'} {currentJob.device || 'Unbekannt'}
</p> </p>
</div> </div>
<div> <div>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p> <p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}> <p className="text-sm font-medium text-slate-700 dark:text-slate-300">
{currentJob.type?.toUpperCase() || 'Unbekannt'} {currentJob.type?.toUpperCase() || 'Unbekannt'}
</p> </p>
</div> </div>
</> </div>
)} )}
</div> </Card>
</div>
) )
} }
+30 -37
View File
@@ -1,11 +1,9 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Search, Loader2, X, Film } from 'lucide-react' import { Search, Loader2, Film } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext' import { Modal } from './ui/Modal'
import { Button } from './ui/Button'
// „Nicht korrekt?!"-Popup (Commander-Wunsch 23.07.): Korrektur direkt am import { Input } from './ui/Input'
// Dashboard — suchen, richtigen Treffer anklicken, Rippy merkt sich die Wahl
// für diese Disc (30 Tage, per Disc-Fingerabdruck).
interface SuchKandidat { interface SuchKandidat {
title: string title: string
@@ -30,7 +28,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([]) const [kandidaten, setKandidaten] = useState<SuchKandidat[]>([])
const [laeuft, setLaeuft] = useState(false) const [laeuft, setLaeuft] = useState(false)
const [fehler, setFehler] = useState<string | null>(null) const [fehler, setFehler] = useState<string | null>(null)
const { theme } = useDarkMode()
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -74,63 +71,60 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
} }
} }
if (!isOpen) return null
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <Modal
<div className={`w-full max-w-2xl max-h-[85vh] flex flex-col rounded-xl shadow-2xl ${theme === 'dark' ? 'bg-slate-800' : 'bg-white'}`}> isOpen={isOpen}
<div className={`px-6 py-4 border-b flex items-center justify-between ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}> onClose={onClose}
<div> title="Richtigen Titel wählen"
<h2 className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Richtigen Titel wählen</h2> maxWidth="2xl"
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> >
<div className="space-y-4">
<p className="text-xs text-slate-500 dark:text-slate-400">
Deine Wahl gilt für diese Disc auch beim nächsten Einlegen. Deine Wahl gilt für diese Disc auch beim nächsten Einlegen.
</p> </p>
</div>
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
<X size={18} />
</button>
</div>
<div className="p-6 overflow-y-auto"> <div className="flex gap-3 items-end">
<div className="flex gap-3"> <div className="flex-1">
<input <Input
value={suchbegriff} value={suchbegriff}
onChange={e => setSuchbegriff(e.target.value)} onChange={e => setSuchbegriff(e.target.value)}
onKeyDown={e => e.key === 'Enter' && suchen()} onKeyDown={e => e.key === 'Enter' && suchen()}
autoFocus autoFocus
placeholder="Titel eingeben…" placeholder="Titel eingeben…"
className={`flex-1 px-4 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-700 border-slate-600 text-slate-100' : 'bg-white border-slate-300 text-slate-900'}`}
/> />
<button </div>
<Button
variant="amber"
onClick={suchen} onClick={suchen}
disabled={!suchbegriff || laeuft} disabled={!suchbegriff || laeuft}
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 transition-colors disabled:opacity-40"
> >
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />} {laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
Suchen Suchen
</button> </Button>
</div> </div>
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>} {fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
{kandidaten.length > 0 && ( {kandidaten.length > 0 && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3.5 pt-2 max-h-[60vh] overflow-y-auto">
{kandidaten.map((k, i) => ( {kandidaten.map((k, i) => (
<button <button
key={i} key={i}
onClick={() => waehlen(k)} onClick={() => waehlen(k)}
className={`text-left rounded-lg border overflow-hidden transition-all hover:border-indigo-500 hover:shadow-lg ${theme === 'dark' ? 'bg-slate-900 border-slate-700' : 'bg-slate-50 border-slate-200'}`} className="text-left rounded-xl border border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80 overflow-hidden transition-all hover:border-amber-500/50 hover:shadow-lg hover:shadow-amber-500/10 group"
> >
{k.poster ? ( {k.poster ? (
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" /> <img src={k.poster} alt={k.title} className="w-full h-40 object-cover group-hover:scale-105 transition-transform duration-300" />
) : ( ) : (
<div className={`w-full h-40 flex items-center justify-center ${theme === 'dark' ? 'bg-slate-800 text-slate-600' : 'bg-slate-100 text-slate-400'}`}> <div className="w-full h-40 flex items-center justify-center bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
<Film size={32} /> <Film size={32} />
</div> </div>
)} )}
<div className="p-2"> <div className="p-2.5">
<p className={`text-xs font-semibold leading-tight line-clamp-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{k.title}</p> <p className="text-xs font-semibold leading-tight line-clamp-2 text-slate-900 dark:text-slate-200 group-hover:text-amber-500 dark:group-hover:text-amber-400 transition-colors">
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> {k.title}
</p>
<p className="text-[11px] mt-1 text-slate-500 dark:text-slate-400">
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source} {k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
</p> </p>
</div> </div>
@@ -139,7 +133,6 @@ export default function MetadataKorrektur({ devicePath, vorschlag, isOpen, onClo
</div> </div>
)} )}
</div> </div>
</div> </Modal>
</div>
) )
} }
+113 -95
View File
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, 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 { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen — import { Button } from './ui/Button'
// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen. import { Input, Select } from './ui/Input'
interface Mount { interface Mount {
name: string name: string
@@ -42,7 +41,6 @@ export default function StorageMounts() {
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([]) const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('') const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false) const [browserOffen, setBrowserOffen] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const browsen = async (pfad: string) => { const browsen = async (pfad: string) => {
@@ -151,35 +149,37 @@ export default function StorageMounts() {
} }
} }
const feld = `w-full px-3 py-2 border rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Vorhandene Ziele */} {/* Vorhandene Ziele */}
<div> <Card>
<div className="flex items-center justify-between mb-3"> <CardHeader>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3> <CardTitle className="flex items-center gap-2">
<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'}`}> <HardDrive size={20} className="text-amber-500" />
<span>Verfügbare Ziele</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Ziele neu laden">
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </Button>
</div> </CardHeader>
<CardContent>
{targets.length === 0 ? ( {targets.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2.5">
{targets.map(t => ( {targets.map(t => (
<div key={t.path} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> <div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<HardDrive size={18} className={t.is_mount ? 'text-emerald-500' : theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} /> <HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
<div> <div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{t.name}</span> <span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<span className={`text-xs ml-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''} {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span> </span>
</div> </div>
</div> </div>
{mounts.some(m => m.name === t.name) && ( {mounts.some(m => m.name === t.name) && (
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"> <button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
)} )}
@@ -187,82 +187,79 @@ export default function StorageMounts() {
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht {/* Ordner-Verwaltung */}
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet <Card>
und verwirrte mehr als sie half (Commander-Feedback 24.07.). */}
<div className={`rounded-lg border overflow-hidden ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
<button <button
onClick={() => setBrowserOffen(!browserOffen)} onClick={() => setBrowserOffen(!browserOffen)}
className={`w-full flex items-center justify-between px-4 py-3 text-left transition-colors ${theme === 'dark' ? 'hover:bg-slate-800' : 'hover:bg-slate-50'}`} className="w-full flex items-center justify-between p-6 text-left transition-colors hover:bg-slate-50/50 dark:hover:bg-slate-800/30"
> >
<div> <div>
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
Ordner-Verwaltung (optional) Ordner-Verwaltung (optional)
</h3> </h3>
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs mt-0.5 text-slate-500 dark:text-slate-400">
Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder Unterordner in der Ablage anschauen und neue anlegen z. B. anime" oder „doku".
„doku", die danach im Rippen starten"-Dialog wählbar sind. Fürs normale
Rippen brauchst du das hier nicht.
</p> </p>
</div> </div>
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}> <span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'} {browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
</span> </span>
</button> </button>
{browserOffen && (<>
<div className={`flex items-center gap-2 px-3 py-2 border-y ${theme === 'dark' ? 'border-slate-700 bg-slate-900' : 'border-slate-200 bg-slate-50'}`}> {browserOffen && (
<div className="border-t border-slate-200/80 dark:border-slate-800/80">
<div className="flex items-center gap-2 px-4 py-2.5 bg-slate-50 dark:bg-slate-950/90 border-b border-slate-200/80 dark:border-slate-800">
<button <button
onClick={() => browseParent && browsen(browseParent)} onClick={() => browseParent && browsen(browseParent)}
disabled={!browseParent} disabled={!browseParent}
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`} className="p-1.5 rounded-lg disabled:opacity-30 hover:bg-slate-200 dark:hover:bg-slate-800 text-slate-500 dark:text-slate-400 transition-colors"
title="Ordner hoch" title="Ordner hoch"
> >
<ArrowUp size={16} /> <ArrowUp size={16} />
</button> </button>
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span> <span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
<input <input
value={neuerOrdner} value={neuerOrdner}
onChange={e => setNeuerOrdner(e.target.value)} onChange={e => setNeuerOrdner(e.target.value)}
placeholder="neuer-ordner" placeholder="neuer-ordner"
className={`w-32 px-2 py-1 text-xs border rounded ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`} className="w-36 px-2.5 py-1 text-xs border rounded-lg bg-white dark:bg-slate-900 border-slate-200 dark:border-slate-700 text-slate-900 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-amber-500/50"
/> />
<button <button
onClick={ordnerAnlegen} onClick={ordnerAnlegen}
disabled={!neuerOrdner} disabled={!neuerOrdner}
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`} className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
title="Ordner anlegen" title="Ordner anlegen"
> >
<FolderPlus size={16} /> <FolderPlus size={16} />
</button> </button>
</div> </div>
<div className="max-h-36 overflow-y-auto"> <div className="max-h-44 overflow-y-auto p-2">
{browseDirs.length === 0 && browseFiles.length === 0 ? ( {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> <p className="text-xs p-3 text-slate-400 dark:text-slate-500">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)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`} className="w-full flex items-center gap-2 px-3 py-2 text-sm rounded-lg text-left transition-colors text-slate-700 dark:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800"
> >
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} /> <Folder size={15} className="text-slate-400 dark:text-slate-500" />
{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 => ( {browseFiles.map(f => (
<div <div
key={f.name} key={f.name}
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`} className="flex items-center gap-2 px-3 py-1.5 text-sm text-slate-400 dark:text-slate-500"
> >
<File size={14} className="flex-shrink-0" /> <File size={14} className="flex-shrink-0" />
<span className="truncate">{f.name}</span> <span className="truncate">{f.name}</span>
{f.size_mb != null && ( {f.size_mb != null && (
<span className="ml-auto text-xs flex-shrink-0"> <span className="ml-auto text-xs flex-shrink-0 font-mono">
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`} {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
</span> </span>
)} )}
@@ -271,91 +268,112 @@ export default function StorageMounts() {
</> </>
)} )}
</div> </div>
</>)}
</div> </div>
)}
</Card>
{/* Neues Netzwerk-Ziel */} {/* Neues Netzwerk-Ziel */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}> <Card>
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <CardHeader>
<Plus size={18} className="text-indigo-500" /> Netzwerk-Ziel hinzufügen <CardTitle className="flex items-center gap-2">
</h3> <Plus size={20} className="text-amber-500" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <span>Netzwerk-Ziel hinzufügen</span>
<div> </CardTitle>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Name (klein, ohne Leerzeichen)</label> </CardHeader>
<input value={name} onChange={e => setName(e.target.value.toLowerCase())} className={feld} placeholder="nas-filme" /> <CardContent className="space-y-4">
</div> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <Input
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</label> label="Name (klein, ohne Leerzeichen)"
<select value={typ} onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')} className={feld}> value={name}
onChange={e => setName(e.target.value.toLowerCase())}
placeholder="nas-filme"
/>
<Select
label="Typ"
value={typ}
onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')}
>
<option value="nfs">NFS (NAS/Linux)</option> <option value="nfs">NFS (NAS/Linux)</option>
<option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option> <option value="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
</select> </Select>
</div>
{typ === 'nfs' ? ( {typ === 'nfs' ? (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Quelle (host:/export/pfad) label="Quelle (host:/export/pfad)"
</label> value={source}
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" /> onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</div> </div>
) : ( ) : (
<> <>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Rechner (dein PC oder NAS Name oder IP) label="Rechner (dein PC oder NAS — Name oder IP)"
</label> value={host}
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" /> onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC"
/>
</div> </div>
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.): <Input
Windows/NAS lehnen Gast-Abfragen fast immer mit label="Benutzer"
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der value={username}
Knopf darunter scheinbar grundlos. */} onChange={e => setUsername(e.target.value)}
<div> autoComplete="off"
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label> placeholder="Freigabe-Benutzer"
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" /> />
</div> <Input
<div> label="Passwort"
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label> type="password"
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" /> value={password}
</div> onChange={e => setPassword(e.target.value)}
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> autoComplete="new-password"
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben />
versucht Rippy einen Gast-Zugriff, der meist mit Zugriff verweigert" endet. <p className="md:col-span-2 text-xs text-slate-500 dark:text-slate-400">
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort ohne Angaben versucht Rippy einen Gast-Zugriff.
</p> </p>
<div className="md:col-span-2"> <div className="md:col-span-2">
<button <Button
variant="secondary"
size="sm"
onClick={freigabenAuflisten} onClick={freigabenAuflisten}
disabled={!host || sharesBusy} disabled={!host || sharesBusy}
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-40 ${theme === 'dark' ? 'bg-slate-700 text-indigo-300 hover:bg-slate-600' : 'bg-slate-200 text-indigo-700 hover:bg-slate-300'}`}
> >
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'} {sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
</button> </Button>
</div> </div>
{shares.length > 0 && ( {shares.length > 0 && (
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Freigabe wählen</label> <Select
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}> label="Freigabe wählen"
value={selectedShare}
onChange={e => setSelectedShare(e.target.value)}
>
{shares.map(s => <option key={s} value={s}>{s}</option>)} {shares.map(s => <option key={s} value={s}>{s}</option>)}
</select> </Select>
</div> </div>
)} )}
</> </>
)} )}
</div> </div>
<button
<Button
variant="amber"
onClick={hinzufuegen} onClick={hinzufuegen}
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))} disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
className="mt-4 px-4 py-2 rounded-lg bg-indigo-600 text-white text-sm font-medium hover:bg-indigo-700 transition-colors disabled:opacity-40"
> >
{busy ? 'Hänge ein…' : 'Einhängen & speichern'} {busy ? 'Hänge ein…' : 'Einhängen & speichern'}
</button> </Button>
{feedback && ( {feedback && (
<p className={`mt-3 text-sm flex items-center gap-2 ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}> <p className={`text-sm flex items-center gap-2 font-medium ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />} {feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
{feedback} {feedback}
</p> </p>
)} )}
</div> </CardContent>
</Card>
</div> </div>
) )
} }
+57 -61
View File
@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react' import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
import { api } from '../lib/api' import { api } from '../lib/api'
import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden, import { Button } from './ui/Button'
// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert import { Input } from './ui/Input'
// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
interface WorkerInfo { interface WorkerInfo {
name: string name: string
@@ -42,13 +39,8 @@ export default function WorkerVerwaltung() {
const [kopiert, setKopiert] = useState(false) const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux') const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
const [rippyHost, setRippyHost] = useState(window.location.hostname) const [rippyHost, setRippyHost] = useState(window.location.hostname)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
// Worker verbinden sich DIREKT mit Redis/Postgres (6379/5432) — das geht
// nur im Heimnetz. Wer das UI gerade über eine externe Domain (Reverse-
// Proxy) aufruft, würde sonst Befehle mit der falschen Adresse kopieren
// (Befund 24.07.: 401 vom Proxy statt Installer).
const siehtOeffentlichAus = const siehtOeffentlichAus =
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) && !/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') && !rippyHost.endsWith('.local') &&
@@ -92,28 +84,32 @@ export default function WorkerVerwaltung() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Verbundene Worker */} {/* Verbundene Worker */}
<div> <Card>
<div className="flex items-center justify-between mb-3"> <CardHeader>
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verbundene Worker</h3> <CardTitle className="flex items-center gap-2">
<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'}`}> <Server size={20} className="text-amber-500" />
<span>Verbundene Worker</span>
</CardTitle>
<Button variant="ghost" size="sm" onClick={() => laden(true)} title="Worker neu laden">
<RefreshCw size={16} /> <RefreshCw size={16} />
</button> </Button>
</div> </CardHeader>
<CardContent>
{workers.length === 0 ? ( {workers.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p> <p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2.5">
{workers.map(w => ( {workers.map(w => (
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}> <div key={w.name} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<span <span
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`} className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500 shadow-sm shadow-emerald-500/50' : 'bg-rose-500'}`}
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'} title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
/> />
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} /> <Server size={18} className="text-slate-400 dark:text-slate-500" />
<div className="min-w-0"> <div className="min-w-0">
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p> <p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}> <p className="text-xs text-slate-500 dark:text-slate-400">
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)} {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
{w.info?.ip ? ` · IP ${w.info.ip}` : ''} {w.info?.ip ? ` · IP ${w.info.ip}` : ''}
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''} {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
@@ -123,7 +119,11 @@ export default function WorkerVerwaltung() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex flex-wrap gap-1 justify-end"> <div className="flex flex-wrap gap-1 justify-end">
{w.encoders.map(e => ( {w.encoders.map(e => (
<span key={e} className={`text-xs px-2 py-0.5 rounded ${e.startsWith('cpu') ? (theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-200 text-slate-600') : 'bg-emerald-600/20 text-emerald-500 font-medium'}`}> <span key={e} className={`text-xs px-2.5 py-0.5 rounded-md font-medium border ${
e.startsWith('cpu')
? 'bg-slate-200/80 dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-700'
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
}`}>
{ENCODER_LABELS[e] || e} {ENCODER_LABELS[e] || e}
</span> </span>
))} ))}
@@ -135,8 +135,8 @@ export default function WorkerVerwaltung() {
.then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() }) .then(() => { toast('success', `${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
.catch(() => toast('error', 'Entfernen fehlgeschlagen')) .catch(() => toast('error', 'Entfernen fehlgeschlagen'))
}} }}
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an" title="Verwaisten Eintrag entfernen"
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10" className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
> >
<Trash2 size={14} /> <Trash2 size={14} />
</button> </button>
@@ -146,69 +146,65 @@ export default function WorkerVerwaltung() {
))} ))}
</div> </div>
)} )}
</div> </CardContent>
</Card>
{/* Neue Maschine anbinden — zwei Varianten */} {/* Neue Maschine anbinden */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}> <Card>
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3> <CardHeader>
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
{/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/ </CardHeader>
Postgres — das geht nur im Heimnetz, nie über die externe Domain */} <CardContent className="space-y-4">
<div className="mb-3"> <div className="max-w-md">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <Input
Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name) label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
</label>
<input
value={rippyHost} value={rippyHost}
onChange={e => setRippyHost(e.target.value.trim())} onChange={e => setRippyHost(e.target.value.trim())}
className={`w-full max-w-xs px-3 py-2 border rounded-lg text-sm font-mono focus:ring-2 focus:ring-indigo-500 focus:border-transparent ${theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-200' : 'bg-white border-slate-300 text-slate-900'}`}
placeholder="192.168.178.162" placeholder="192.168.178.162"
/> />
{siehtOeffentlichAus && ( {siehtOeffentlichAus && (
<p className={`text-xs mt-1.5 px-2.5 py-1.5 rounded-lg inline-block ${theme === 'dark' ? 'bg-amber-900/30 text-amber-300' : 'bg-amber-50 text-amber-700'}`}> <p className="text-xs mt-2 p-3 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-700 dark:text-amber-300">
Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
direkt mit Redis/Postgres (Ports 6379/5432) das geht nur im Heimnetz, und der direkt mit Redis/Postgres (Ports 6379/5432) das geht nur im Heimnetz. Hier die <strong>LAN-IP</strong> eintragen, z. B. 192.168.178.162.
Proxy blockt den Installer-Download (401). Hier die <strong>LAN-IP</strong> der
Rippy-Maschine eintragen, z. B. 192.168.178.162.
</p> </p>
)} )}
</div> </div>
<div className="flex gap-2 mb-3"> <div className="flex gap-2">
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => ( {([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
<button <Button
key={wert} key={wert}
variant={variante === wert ? 'amber' : 'secondary'}
size="sm"
onClick={() => setVariante(wert)} onClick={() => setVariante(wert)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
variante === 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'
}`}
> >
{label} {label}
</button> </Button>
))} ))}
</div> </div>
<p className={`text-xs mb-3 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
<p className="text-xs text-slate-500 dark:text-slate-400">
{variante === 'linux' {variante === 'linux'
? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.' ? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
: 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch und erzeugt: start-tray.bat (Tray-Symbol neben der Uhr mit Start/Stopp/Status), start-worker.bat (Konsole, für Fehlersuche) und uninstall.ps1 (entfernt alles rückstandsfrei inkl. Worker-Eintrag hier). Autostart bei Anmeldung: Parameter -Autostart anhängen.'} : 'In einer PowerShell auf dem Windows-PC ausführen (braucht nur Python 3.10+ — kein Docker, kein git). Der Installer holt Worker-Code und HandBrake automatisch.'}
</p> </p>
<div className={`relative rounded-lg p-3 font-mono text-xs whitespace-pre overflow-x-auto ${theme === 'dark' ? 'bg-slate-950 text-slate-300' : 'bg-slate-900 text-slate-200'}`}>
<div className="relative rounded-xl p-4 font-mono text-xs whitespace-pre overflow-x-auto bg-slate-900 text-slate-200 border border-slate-800">
{installBefehl} {installBefehl}
<button <button
onClick={kopieren} onClick={kopieren}
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600" className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
title="Kopieren" title="Kopieren"
> >
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />} {kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
</button> </button>
</div> </div>
<p className={`text-xs mt-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, <p className="text-xs text-slate-500 dark:text-slate-400">
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz. Beide Varianten bedienen nur die Kompressions-Queue gerippt wird immer hier, wo das Laufwerk hängt.
</p> </p>
</div> </CardContent>
</Card>
</div> </div>
) )
} }
+19 -23
View File
@@ -1,37 +1,33 @@
import { ReactNode } from 'react' import { ReactNode } from 'react'
import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react' import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
import { useDarkMode } from '../context/ThemeContext' import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) { function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
const { theme } = useDarkMode()
return ( return (
<section className={`rounded-xl border p-6 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card>
<h2 className={`text-lg font-semibold mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <CardHeader>
<Icon size={20} className={theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'} /> <CardTitle className="flex items-center gap-2.5">
{titel} <Icon size={20} className="text-amber-500" />
</h2> <span>{titel}</span>
<div className={`space-y-2 text-sm leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}> </CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm leading-relaxed text-slate-600 dark:text-slate-300">
{children} {children}
</div> </CardContent>
</section> </Card>
) )
} }
export default function AnleitungPage() { export default function AnleitungPage() {
const { theme } = useDarkMode() const fett = "font-semibold text-slate-900 dark:text-slate-100"
const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
return ( return (
<div className="space-y-6 max-w-4xl transition-colors duration-300"> <div className="space-y-6 max-w-4xl">
<div className="flex flex-col gap-2"> <PageHeader
<h1 className={`text-3xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Anleitung</h1> title="Anleitung & Hilfe"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> subtitle="So funktioniert Rippy — vom Einlegen der Disc bis zum fertigen Film in deinem Media-Server"
So funktioniert Rippy vom Einlegen bis zum fertigen Film in deinem Media-Server. />
</p>
</div>
<Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig"> <Abschnitt icon={Disc} titel="Der normale Weg: Disc rein, fertig">
<p> <p>
+85 -118
View File
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react' 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 { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { PageHeader } from '../components/ui/PageHeader'
import { Card, CardContent } from '../components/ui/Card'
import { Button } from '../components/ui/Button'
interface LogEntry { interface LogEntry {
id: string id: string
@@ -18,12 +20,9 @@ export default function LogsPage() {
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 [refreshing, setRefreshing] = useState(false)
const { theme } = useDarkMode()
const { toast } = useToast() const { toast } = useToast()
const refreshLogs = async (manuell = false) => { const refreshLogs = async (manuell = false) => {
// Echte Ereignisse aus der Datenbank — die Mock-Einträge („The Matrix
// gerippt") sind raus. Was hier steht, ist passiert; sonst ist es leer.
if (manuell) setRefreshing(true) if (manuell) setRefreshing(true)
try { try {
const response = await api.get('/logs', { params: { limit: 200 } }) const response = await api.get('/logs', { params: { limit: 200 } })
@@ -46,19 +45,19 @@ export default function LogsPage() {
const getLevelColor = (level: string) => { const getLevelColor = (level: string) => {
switch (level) { switch (level) {
case 'error': return theme === 'dark' ? 'bg-rose-900/30 text-rose-400 border-rose-700/20' : 'bg-rose-100 text-rose-700 border-rose-600/20' case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
case 'warning': return theme === 'dark' ? 'bg-amber-900/30 text-amber-400 border-amber-700/20' : 'bg-amber-100 text-amber-700 border-amber-600/20' case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
case 'success': return theme === 'dark' ? 'bg-emerald-900/30 text-emerald-400 border-emerald-700/20' : 'bg-emerald-100 text-emerald-700 border-emerald-600/20' case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
default: return theme === 'dark' ? 'bg-blue-900/30 text-blue-400 border-blue-700/20' : 'bg-blue-100 text-blue-700 border-blue-600/20' default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
} }
} }
const getLevelIcon = (level: string) => { const getLevelIcon = (level: string) => {
switch (level) { switch (level) {
case 'error': return <AlertCircle size={16} /> case 'error': return <AlertCircle size={15} />
case 'warning': return <AlertCircle size={16} /> case 'warning': return <AlertCircle size={15} />
case 'success': return <CheckCircle size={16} /> case 'success': return <CheckCircle size={15} />
default: return <Terminal size={16} /> default: return <Terminal size={15} />
} }
} }
@@ -83,153 +82,121 @@ export default function LogsPage() {
} }
return ( return (
<div className="space-y-6 transition-colors duration-300"> <div className="space-y-6">
{/* Header */} <PageHeader
<div className="flex flex-col gap-2 transition-colors duration-300"> title="System-Logs"
<h1 className="text-3xl font-bold dark:text-slate-100 text-slate-900">Logs</h1> subtitle="Alle relevanten Ereignisse und Protokolle des Ripping-Systems auf einen Blick"
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Alle relevanten Log-Informationen des Ripping-Systems</p> action={
</div> <Button variant="secondary" onClick={() => refreshLogs(true)}>
<RefreshCw size={15} className={loading || refreshing ? 'animate-spin' : ''} />
Neu laden
</Button>
}
/>
{/* Stats */} {/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 transition-colors duration-300"> <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card>
<div className="flex items-center gap-3 mb-2"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-500"> <div className="p-2.5 rounded-xl bg-sky-500/15 text-sky-500 border border-sky-500/20">
<Terminal size={18} /> <Terminal size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Info</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-amber-500/15 text-amber-500 border border-amber-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Warnung</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-emerald-500/15 text-emerald-500 border border-emerald-500/20">
<CheckCircle size={18} /> <CheckCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Erfolg</span> <div>
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
</div> </div>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p> </CardContent>
</div> </Card>
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="flex items-center gap-3 mb-2"> <Card>
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500"> <CardContent className="p-4 flex items-center gap-3">
<div className="p-2.5 rounded-xl bg-rose-500/15 text-rose-500 border border-rose-500/20">
<AlertCircle size={18} /> <AlertCircle size={18} />
</div> </div>
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Fehler</span> <div>
</div> <span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Fehler</span>
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.error}</p> <p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.error}</p>
</div> </div>
</CardContent>
</Card>
</div> </div>
{/* Filters */} {/* Filters & Log List */}
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}> <Card className="overflow-hidden">
<div className="flex flex-wrap items-center gap-3"> <div className="px-6 py-4 border-b border-slate-200/80 dark:border-slate-800 flex items-center gap-2 flex-wrap bg-slate-50/50 dark:bg-slate-900/50">
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Filter:</span> <span className="text-xs font-medium text-slate-500 dark:text-slate-400 mr-2">Filter:</span>
{['all', 'info', 'success', 'warning', 'error'].map(f => (
<button <button
onClick={() => setFilter('all')} key={f}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${ onClick={() => setFilter(f)}
filter === 'all' className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
? 'bg-indigo-600 text-white' filter === f
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200' ? 'bg-amber-500 text-slate-950 font-bold shadow-md shadow-amber-500/20'
: 'bg-slate-200/70 dark:bg-slate-800 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-700'
}`} }`}
> >
Alle {f === 'all' ? 'Alle' : getLevelLabel(f)}
</button> </button>
<button ))}
onClick={() => setFilter('info')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'info'
? 'bg-blue-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'
}`}
>
Info
</button>
<button
onClick={() => setFilter('success')}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
filter === 'success'
? 'bg-emerald-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'
}`}
>
Erfolg
</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" />
<button
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'}`}
>
<RefreshCw size={14} className={loading || refreshing ? 'animate-spin' : ''} />
Neu laden
</button>
</div>
</div> </div>
{/* Logs List */}
<div className={`rounded-lg border overflow-hidden transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
<div className="max-h-[600px] overflow-y-auto"> <div className="max-h-[600px] overflow-y-auto">
{filteredLogs.length === 0 ? ( {filteredLogs.length === 0 ? (
<div className="text-center py-12 transition-colors duration-300"> <div className="text-center py-16">
<div className={`mx-auto w-16 h-16 rounded-full flex items-center justify-center mb-4 ${theme === 'dark' ? 'bg-slate-700' : 'bg-slate-100'}`}> <div className="mx-auto w-16 h-16 rounded-2xl flex items-center justify-center mb-3 bg-slate-100 dark:bg-slate-800 text-slate-400 border border-slate-200 dark:border-slate-700">
<Terminal className={theme === 'dark' ? 'text-slate-400' : 'text-slate-400'} size={32} /> <Terminal size={32} />
</div> </div>
<p className={`text-sm ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Keine Logs gefunden</p> <p className="text-sm text-slate-500 dark:text-slate-400">Keine Logs für diesen Filter vorhanden</p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-slate-200 dark:divide-slate-700"> <div className="divide-y divide-slate-200/80 dark:divide-slate-800">
{filteredLogs.map(log => ( {filteredLogs.map(log => (
<div key={log.id} className={`p-4 transition-colors duration-300 hover:${theme === 'dark' ? 'bg-slate-700/50' : 'bg-slate-50'}`}> <div key={log.id} className="p-4 transition-colors hover:bg-slate-50/80 dark:hover:bg-slate-800/40">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`mt-1 p-1.5 rounded ${getLevelColor(log.level)}`}> <div className={`mt-0.5 p-1.5 rounded-lg border ${getLevelColor(log.level)}`}>
{getLevelIcon(log.level)} {getLevelIcon(log.level)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap"> <div className="flex items-center gap-2 mb-1 flex-wrap">
<span className={`text-xs font-mono px-1.5 py-0.5 rounded ${theme === 'dark' ? 'bg-slate-700 text-slate-400' : 'bg-slate-100 text-slate-600'}`}> <span className="text-xs font-mono px-2 py-0.5 rounded-md bg-slate-100 dark:bg-slate-800 text-slate-600 dark:text-slate-400 border border-slate-200 dark:border-slate-700">
{log.source} {log.source}
</span> </span>
<span className={`text-xs font-medium ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> <span className="text-xs text-slate-400 font-mono">
{log.timestamp} {log.timestamp}
</span> </span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${getLevelColor(log.level)}`}> <span className={`text-xs font-medium px-2 py-0.5 rounded-full border ${getLevelColor(log.level)}`}>
{getLevelLabel(log.level)} {getLevelLabel(log.level)}
</span> </span>
</div> </div>
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}> <p className="text-sm font-medium text-slate-900 dark:text-slate-100">
{log.message} {log.message}
</p> </p>
{log.details && ( {log.details && (
<p className={`text-xs mt-1 font-mono ${theme === 'dark' ? 'text-slate-500' : 'text-slate-500'}`}> <p className="text-xs mt-1 font-mono text-slate-500 dark:text-slate-400">
{log.details} {log.details}
</p> </p>
)} )}
@@ -240,7 +207,7 @@ export default function LogsPage() {
</div> </div>
)} )}
</div> </div>
</div> </Card>
</div> </div>
) )
} }