refactor(ui): Modals & Unterseiten auf Primitives & dark: umgestellt
This commit is contained in:
+46
-38
@@ -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>
|
||||||
|
|
||||||
<button
|
<div className="px-3 mb-2">
|
||||||
onClick={toggleTheme}
|
<button
|
||||||
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"
|
onClick={toggleTheme}
|
||||||
title={theme === 'dark' ? 'Light Mode' : 'Light Mode'}
|
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 aktivieren' : 'Dark Mode aktivieren'}
|
||||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
>
|
||||||
</button>
|
<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>
|
||||||
|
</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) => {
|
||||||
<button
|
const isActive = page === item.id
|
||||||
key={item.id}
|
return (
|
||||||
onClick={() => setPage(item.id as Page)}
|
<button
|
||||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
|
key={item.id}
|
||||||
page === item.id
|
onClick={() => setPage(item.id as Page)}
|
||||||
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
|
className={`w-full flex items-center gap-3 px-3.5 py-2.5 rounded-xl transition-all duration-200 font-medium text-sm ${
|
||||||
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
isActive
|
||||||
}`}
|
? '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/50 hover:text-slate-200 border-l-4 border-transparent'
|
||||||
<item.icon size={20} />
|
}`}
|
||||||
<span className="font-medium">{item.label}</span>
|
>
|
||||||
</button>
|
<item.icon size={19} className={isActive ? 'text-amber-400' : 'text-slate-400'} />
|
||||||
))}
|
<span>{item.label}</span>
|
||||||
|
</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 />}
|
||||||
|
|||||||
@@ -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 */}
|
{getIcon()}
|
||||||
<div className={`px-6 py-4 border-b transition-colors duration-300 ${theme === 'dark' ? 'border-slate-700' : 'border-slate-200'}`}>
|
<div className="space-y-2 flex-1">
|
||||||
<div className="flex items-center gap-3">
|
<h2 className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
{getIcon()}
|
{title}
|
||||||
<h2 className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
</h2>
|
||||||
{title}
|
<p className="text-sm leading-relaxed text-slate-600 dark:text-slate-300">
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
</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>
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<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
|
|
||||||
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}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={onConfirm}
|
|
||||||
className={`px-4 py-2 rounded-lg text-sm font-medium text-white transition-colors ${getConfirmColor()}`}
|
|
||||||
>
|
|
||||||
{confirmText}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<div className="mt-6 flex justify-end gap-3 border-t border-slate-200 dark:border-slate-800 pt-4">
|
||||||
|
<Button variant="secondary" onClick={onClose}>
|
||||||
|
{cancelText}
|
||||||
|
</Button>
|
||||||
|
<Button variant={getConfirmVariant()} onClick={onConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & modern (Standard)</option>
|
<option value="H.265 MKV 1080p30">H.265 — klein & 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>
|
||||||
|
|||||||
@@ -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,154 +72,148 @@ 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'}`}>
|
{laedt ? (
|
||||||
{detail?.title || 'Job-Details'}
|
<div className="flex items-center justify-center py-16">
|
||||||
</h2>
|
<Loader2 className="animate-spin text-amber-500" size={28} />
|
||||||
<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>
|
</div>
|
||||||
|
) : !detail ? (
|
||||||
{laedt ? (
|
<p className="px-6 py-10 text-sm text-center text-slate-500 dark:text-slate-400">
|
||||||
<div className="flex items-center justify-center py-16">
|
Job nicht gefunden.
|
||||||
<Loader2 className="animate-spin text-indigo-500" size={28} />
|
</p>
|
||||||
</div>
|
) : (
|
||||||
) : !detail ? (
|
<div className="space-y-6">
|
||||||
<p className={`px-6 py-10 text-sm text-center ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
||||||
Job nicht gefunden.
|
<div className="flex gap-5">
|
||||||
</p>
|
{poster ? (
|
||||||
) : (
|
<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="p-6 space-y-5">
|
) : (
|
||||||
{/* Film/Serien-Info aus der Disc-Erkennung */}
|
<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">
|
||||||
<div className="flex gap-5">
|
<Film size={36} />
|
||||||
{poster ? (
|
|
||||||
<img src={poster} alt={detail.title || ''} className="w-32 rounded-lg object-cover flex-shrink-0 shadow-lg" />
|
|
||||||
) : (
|
|
||||||
<div className={`w-32 h-48 rounded-lg flex items-center justify-center flex-shrink-0 ${theme === 'dark' ? 'bg-slate-900 text-slate-600' : 'bg-slate-100 text-slate-400'}`}>
|
|
||||||
<Film size={32} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className={`text-xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
||||||
{detail.title || 'Unbekannt'}
|
|
||||||
{meta?.year ? <span className={`font-normal ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}> ({meta.year})</span> : null}
|
|
||||||
</p>
|
|
||||||
<p className={`text-xs mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
|
||||||
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
|
||||||
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
|
||||||
</p>
|
|
||||||
{meta?.genres && meta.genres.length > 0 && (
|
|
||||||
<div className="flex flex-wrap gap-1.5 mt-2">
|
|
||||||
{meta.genres.map(g => (
|
|
||||||
<span key={g} className={`text-xs px-2 py-0.5 rounded-full ${theme === 'dark' ? 'bg-slate-700 text-slate-300' : 'bg-slate-100 text-slate-600'}`}>{g}</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{meta?.overview ? (
|
|
||||||
<p className={`text-sm mt-3 leading-relaxed ${theme === 'dark' ? 'text-slate-300' : 'text-slate-600'}`}>
|
|
||||||
{meta.overview}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className={`text-sm mt-3 italic ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Keine Metadaten zu diesem Job gespeichert — die Disc wurde ohne Erkennung gerippt.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
{/* Nüchterne Job-Fakten */}
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<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'}`}>
|
<h3 className="text-xl font-bold text-slate-900 dark:text-slate-100">
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Status</span>
|
{detail.title || 'Unbekannt'}
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
{meta?.year ? <span className="font-normal text-slate-500 dark:text-slate-400"> ({meta.year})</span> : null}
|
||||||
{STATUS_LABEL[detail.status] || detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
</h3>
|
||||||
</span>
|
<StatusBadge status={detail.status} />
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Laufwerk</span>
|
</div>
|
||||||
<span className={`font-mono text-xs self-center ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>{detail.device || '—'}</span>
|
<p className="text-xs mt-1 text-slate-500 dark:text-slate-400">
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Gestartet</span>
|
{meta?.type === 'tv' ? 'Serie' : 'Film'}
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
{meta?.source ? ` · Quelle: ${meta.source}` : ''}
|
||||||
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
{meta?.runtime ? ` · ${meta.runtime} min` : ''}
|
||||||
</span>
|
</p>
|
||||||
<span className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}>Beendet</span>
|
{meta?.genres && meta.genres.length > 0 && (
|
||||||
<span className={theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}>
|
<div className="flex flex-wrap gap-1.5 mt-2">
|
||||||
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
{meta.genres.map(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">
|
||||||
{(detail.output_path || detail.target_dir) && (
|
{g}
|
||||||
<>
|
</span>
|
||||||
<span className={`flex items-center gap-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}><FolderOpen size={13} /> Ablage</span>
|
|
||||||
<span className={`font-mono text-xs self-center break-all ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
|
||||||
{detail.output_path || detail.target_dir}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Download der fertigen Dateien — vorher kam man nur per scp dran */}
|
|
||||||
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
|
||||||
<div className={`rounded-lg p-4 ${theme === 'dark' ? 'bg-slate-900' : 'bg-slate-50'}`}>
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<p className={`text-sm font-medium flex items-center gap-2 ${theme === 'dark' ? 'text-slate-200' : 'text-slate-700'}`}>
|
|
||||||
<Download size={15} /> Dateien herunterladen
|
|
||||||
</p>
|
|
||||||
{dateien.length > 1 && (
|
|
||||||
<button
|
|
||||||
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) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
const a = document.createElement('a')
|
|
||||||
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
|
|
||||||
a.download = f.name
|
|
||||||
document.body.appendChild(a)
|
|
||||||
a.click()
|
|
||||||
a.remove()
|
|
||||||
}, 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})
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{dateien.map(f => (
|
|
||||||
<a
|
|
||||||
key={f.name}
|
|
||||||
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
<Download size={14} className="flex-shrink-0" />
|
|
||||||
<span className="truncate min-w-0">{f.name}</span>
|
|
||||||
{f.size_mb != null && (
|
|
||||||
<span className={`ml-auto text-xs flex-shrink-0 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</a>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
{meta?.overview ? (
|
||||||
|
<p className="text-sm mt-3 leading-relaxed text-slate-600 dark:text-slate-300 line-clamp-4">
|
||||||
|
{meta.overview}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{detail.error && (
|
{/* Job-Fakten Grid */}
|
||||||
<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 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">
|
||||||
<AlertCircle size={16} className="flex-shrink-0 mt-0.5" />
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Status</span>
|
||||||
<span className="break-words min-w-0">{detail.error}</span>
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
</div>
|
{detail.status}{detail.progress > 0 && detail.progress < 100 ? ` · ${detail.progress} %` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Laufwerk</span>
|
||||||
|
<span className="font-mono text-xs self-center text-slate-900 dark:text-slate-200">{detail.device || '—'}</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Gestartet</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.startTime ? new Date(detail.startTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
<span className="text-slate-500 dark:text-slate-400 font-medium">Beendet</span>
|
||||||
|
<span className="text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.endTime ? new Date(detail.endTime).toLocaleString('de-DE') : '—'}
|
||||||
|
</span>
|
||||||
|
{(detail.output_path || detail.target_dir) && (
|
||||||
|
<>
|
||||||
|
<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 text-slate-900 dark:text-slate-200">
|
||||||
|
{detail.output_path || detail.target_dir}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
{/* Dateiliste & Download */}
|
||||||
</div>
|
{detail.status === 'completed' && dateien && dateien.length > 0 && (
|
||||||
|
<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-3">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2 text-slate-900 dark:text-slate-200">
|
||||||
|
<Download size={15} className="text-amber-500" /> Dateien herunterladen
|
||||||
|
</p>
|
||||||
|
{dateien.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
dateien.forEach((f, i) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = `/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`
|
||||||
|
a.download = f.name
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
}, i * 400)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Alle herunterladen ({dateien.length})
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{dateien.map(f => (
|
||||||
|
<a
|
||||||
|
key={f.name}
|
||||||
|
href={`/api/jobs/${detail.id}/files/${encodeURIComponent(f.name)}`}
|
||||||
|
download
|
||||||
|
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" />
|
||||||
|
<span className="truncate min-w-0">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<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`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.error && (
|
||||||
|
<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" />
|
||||||
|
<span className="break-words min-w-0">{detail.error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 text-slate-500 dark:text-slate-400">Fortschritt</p>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Fortschritt</p>
|
<p className="text-lg font-bold text-slate-900 dark:text-slate-100">
|
||||||
<p className={`text-lg font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
{currentJob.progress}%
|
||||||
{currentJob.progress}%
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Gerät</p>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Gerät</p>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{currentJob.device || 'Unbekannt'}
|
||||||
{currentJob.device || 'Unbekannt'}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<p className="text-xs text-slate-500 dark:text-slate-400">Typ</p>
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Typ</p>
|
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||||||
<p className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-300' : 'text-slate-700'}`}>
|
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
||||||
{currentJob.type?.toUpperCase() || 'Unbekannt'}
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
)}
|
||||||
)}
|
</Card>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,72 +71,68 @@ 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'}`}>
|
>
|
||||||
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
<div className="space-y-4">
|
||||||
</p>
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
</div>
|
Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
|
||||||
<button onClick={onClose} className={`p-1.5 rounded ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-100 text-slate-500'}`}>
|
</p>
|
||||||
<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
|
|
||||||
onClick={suchen}
|
|
||||||
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} />}
|
|
||||||
Suchen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
{fehler && <p className="text-sm text-rose-500 mt-3">{fehler}</p>}
|
variant="amber"
|
||||||
|
onClick={suchen}
|
||||||
{kandidaten.length > 0 && (
|
disabled={!suchbegriff || laeuft}
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mt-4">
|
>
|
||||||
{kandidaten.map((k, i) => (
|
{laeuft ? <Loader2 className="animate-spin" size={18} /> : <Search size={18} />}
|
||||||
<button
|
Suchen
|
||||||
key={i}
|
</Button>
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
{k.poster ? (
|
|
||||||
<img src={k.poster} alt={k.title} className="w-full h-40 object-cover" />
|
|
||||||
) : (
|
|
||||||
<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'}`}>
|
|
||||||
<Film size={32} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="p-2">
|
|
||||||
<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 mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
{k.year || '—'} · {k.type === 'tv' ? 'Serie' : 'Film'} · {k.source}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fehler && <p className="text-sm text-rose-500 font-medium">{fehler}</p>}
|
||||||
|
|
||||||
|
{kandidaten.length > 0 && (
|
||||||
|
<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) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
onClick={() => waehlen(k)}
|
||||||
|
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 ? (
|
||||||
|
<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 bg-slate-200 dark:bg-slate-900 text-slate-400 dark:text-slate-600">
|
||||||
|
<Film size={32} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="p-2.5">
|
||||||
|
<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">
|
||||||
|
{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}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Modal>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,211 +149,231 @@ 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>
|
||||||
{targets.length === 0 ? (
|
<CardContent>
|
||||||
<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>
|
{targets.length === 0 ? (
|
||||||
) : (
|
<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">
|
|
||||||
{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 className="flex items-center gap-3">
|
|
||||||
<HardDrive size={18} className={t.is_mount ? 'text-emerald-500' : theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
|
||||||
<div>
|
|
||||||
<span className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{t.name}</span>
|
|
||||||
<span className={`text-xs ml-2 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{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">
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Ordner-Verwaltung — bewusst eingeklappt: fürs tägliche Rippen nicht
|
|
||||||
nötig (Ziel wählt man im „Rippen starten"-Dialog), war unbeschriftet
|
|
||||||
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
|
|
||||||
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'}`}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<h3 className={`text-sm font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
|
|
||||||
Ordner-Verwaltung (optional)
|
|
||||||
</h3>
|
|
||||||
<p className={`text-xs mt-0.5 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder
|
|
||||||
„doku", die danach im „Rippen starten"-Dialog wählbar sind. Fürs normale
|
|
||||||
Rippen brauchst du das hier nicht.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs flex-shrink-0 ml-3 ${theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600'}`}>
|
|
||||||
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
|
||||||
</span>
|
|
||||||
</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'}`}>
|
|
||||||
<button
|
|
||||||
onClick={() => browseParent && browsen(browseParent)}
|
|
||||||
disabled={!browseParent}
|
|
||||||
className={`p-1 rounded disabled:opacity-30 ${theme === 'dark' ? 'hover:bg-slate-700 text-slate-400' : 'hover:bg-slate-200 text-slate-500'}`}
|
|
||||||
title="Ordner hoch"
|
|
||||||
>
|
|
||||||
<ArrowUp size={16} />
|
|
||||||
</button>
|
|
||||||
<span className={`text-xs font-mono truncate flex-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>{browsePath}</span>
|
|
||||||
<input
|
|
||||||
value={neuerOrdner}
|
|
||||||
onChange={e => setNeuerOrdner(e.target.value)}
|
|
||||||
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'}`}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={ordnerAnlegen}
|
|
||||||
disabled={!neuerOrdner}
|
|
||||||
className={`p-1.5 rounded disabled:opacity-30 text-indigo-500 ${theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-200'}`}
|
|
||||||
title="Ordner anlegen"
|
|
||||||
>
|
|
||||||
<FolderPlus size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div className="max-h-36 overflow-y-auto">
|
|
||||||
{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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="space-y-2.5">
|
||||||
{browseDirs.map(d => (
|
{targets.map(t => (
|
||||||
<button
|
<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">
|
||||||
key={d.path}
|
<div className="flex items-center gap-3">
|
||||||
onClick={() => browsen(d.path)}
|
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
|
||||||
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'}`}
|
<div>
|
||||||
>
|
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
|
||||||
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
|
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
|
||||||
{d.name}
|
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
|
||||||
</button>
|
</span>
|
||||||
))}
|
</div>
|
||||||
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
|
</div>
|
||||||
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
|
{mounts.some(m => m.name === t.name) && (
|
||||||
{browseFiles.map(f => (
|
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
|
||||||
<div
|
<Trash2 size={16} />
|
||||||
key={f.name}
|
</button>
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}
|
|
||||||
>
|
|
||||||
<File size={14} className="flex-shrink-0" />
|
|
||||||
<span className="truncate">{f.name}</span>
|
|
||||||
{f.size_mb != null && (
|
|
||||||
<span className="ml-auto text-xs flex-shrink-0">
|
|
||||||
{f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
</>)}
|
</Card>
|
||||||
</div>
|
|
||||||
|
{/* Ordner-Verwaltung */}
|
||||||
|
<Card>
|
||||||
|
<button
|
||||||
|
onClick={() => setBrowserOffen(!browserOffen)}
|
||||||
|
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>
|
||||||
|
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
|
||||||
|
Ordner-Verwaltung (optional)
|
||||||
|
</h3>
|
||||||
|
<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 „doku".
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs flex-shrink-0 ml-3 text-amber-600 dark:text-amber-400 font-medium">
|
||||||
|
{browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{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
|
||||||
|
onClick={() => browseParent && browsen(browseParent)}
|
||||||
|
disabled={!browseParent}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<ArrowUp size={16} />
|
||||||
|
</button>
|
||||||
|
<span className="text-xs font-mono truncate flex-1 text-slate-500 dark:text-slate-400">{browsePath}</span>
|
||||||
|
<input
|
||||||
|
value={neuerOrdner}
|
||||||
|
onChange={e => setNeuerOrdner(e.target.value)}
|
||||||
|
placeholder="neuer-ordner"
|
||||||
|
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
|
||||||
|
onClick={ordnerAnlegen}
|
||||||
|
disabled={!neuerOrdner}
|
||||||
|
className="p-1.5 rounded-lg disabled:opacity-30 text-amber-500 hover:bg-amber-500/10 transition-colors"
|
||||||
|
title="Ordner anlegen"
|
||||||
|
>
|
||||||
|
<FolderPlus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-44 overflow-y-auto p-2">
|
||||||
|
{browseDirs.length === 0 && browseFiles.length === 0 ? (
|
||||||
|
<p className="text-xs p-3 text-slate-400 dark:text-slate-500">Ordner ist leer</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{browseDirs.map(d => (
|
||||||
|
<button
|
||||||
|
key={d.path}
|
||||||
|
onClick={() => browsen(d.path)}
|
||||||
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm 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="text-slate-400 dark:text-slate-500" />
|
||||||
|
{d.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{browseFiles.map(f => (
|
||||||
|
<div
|
||||||
|
key={f.name}
|
||||||
|
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" />
|
||||||
|
<span className="truncate">{f.name}</span>
|
||||||
|
{f.size_mb != null && (
|
||||||
|
<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`}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</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">
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Quelle (host:/export/pfad)
|
|
||||||
</label>
|
|
||||||
<input value={source} onChange={e => setSource(e.target.value)} className={feld} placeholder="192.168.1.10:/volume1/filme" />
|
|
||||||
</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="Quelle (host:/export/pfad)"
|
||||||
</label>
|
value={source}
|
||||||
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
|
onChange={e => setSource(e.target.value)}
|
||||||
|
placeholder="192.168.1.10:/volume1/filme"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Zugangsdaten VOR der Freigaben-Abfrage (Befund 24.07.):
|
) : (
|
||||||
Windows/NAS lehnen Gast-Abfragen fast immer mit
|
<>
|
||||||
ACCESS_DENIED ab — ohne Benutzer/Passwort scheiterte der
|
|
||||||
Knopf darunter scheinbar grundlos. */}
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Benutzer</label>
|
|
||||||
<input value={username} onChange={e => setUsername(e.target.value)} className={feld} autoComplete="off" placeholder="Freigabe-Benutzer" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Passwort</label>
|
|
||||||
<input type="password" value={password} onChange={e => setPassword(e.target.value)} className={feld} autoComplete="new-password" />
|
|
||||||
</div>
|
|
||||||
<p className={`md:col-span-2 text-xs -mt-1 ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
|
||||||
Windows-PCs und die meisten NAS verlangen Benutzer + Passwort — ohne Angaben
|
|
||||||
versucht Rippy einen Gast-Zugriff, der meist mit „Zugriff verweigert" endet.
|
|
||||||
</p>
|
|
||||||
<div className="md:col-span-2">
|
|
||||||
<button
|
|
||||||
onClick={freigabenAuflisten}
|
|
||||||
disabled={!host || sharesBusy}
|
|
||||||
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'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{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>
|
<Input
|
||||||
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}>
|
label="Rechner (dein PC oder NAS — Name oder IP)"
|
||||||
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
value={host}
|
||||||
</select>
|
onChange={e => setHost(e.target.value)}
|
||||||
|
placeholder="192.168.178.20 oder TOBIS-PC"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<Input
|
||||||
</>
|
label="Benutzer"
|
||||||
|
value={username}
|
||||||
|
onChange={e => setUsername(e.target.value)}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder="Freigabe-Benutzer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Passwort"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={freigabenAuflisten}
|
||||||
|
disabled={!host || sharesBusy}
|
||||||
|
>
|
||||||
|
{sharesBusy ? 'Suche…' : 'Freigaben auflisten'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{shares.length > 0 && (
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<Select
|
||||||
|
label="Freigabe wählen"
|
||||||
|
value={selectedShare}
|
||||||
|
onChange={e => setSelectedShare(e.target.value)}
|
||||||
|
>
|
||||||
|
{shares.map(s => <option key={s} value={s}>{s}</option>)}
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="amber"
|
||||||
|
onClick={hinzufuegen}
|
||||||
|
disabled={busy || !name || (typ === 'nfs' ? !source : !(host && selectedShare))}
|
||||||
|
>
|
||||||
|
{busy ? 'Hänge ein…' : 'Einhängen & speichern'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{feedback && (
|
||||||
|
<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}
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</CardContent>
|
||||||
<button
|
</Card>
|
||||||
onClick={hinzufuegen}
|
|
||||||
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'}
|
|
||||||
</button>
|
|
||||||
{feedback && (
|
|
||||||
<p className={`mt-3 text-sm flex items-center gap-2 ${feedback.startsWith('✓') ? 'text-emerald-500' : 'text-rose-500'}`}>
|
|
||||||
{feedback.startsWith('✓') ? <CheckCircle size={16} /> : <AlertCircle size={16} />}
|
|
||||||
{feedback}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,123 +84,127 @@ 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>
|
||||||
{workers.length === 0 ? (
|
<CardContent>
|
||||||
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch kein Worker gemeldet.</p>
|
{workers.length === 0 ? (
|
||||||
) : (
|
<p className="text-sm text-slate-500 dark:text-slate-400">Noch kein Worker gemeldet.</p>
|
||||||
<div className="space-y-2">
|
) : (
|
||||||
{workers.map(w => (
|
<div className="space-y-2.5">
|
||||||
<div key={w.name} className={`flex items-center justify-between p-3 rounded-lg ${theme === 'dark' ? 'bg-slate-800' : 'bg-slate-50'}`}>
|
{workers.map(w => (
|
||||||
<div className="flex items-center gap-3 min-w-0">
|
<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">
|
||||||
<span
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
className={`w-2.5 h-2.5 rounded-full flex-shrink-0 ${w.online ? 'bg-emerald-500' : 'bg-rose-500'}`}
|
<span
|
||||||
title={w.online ? 'erreichbar (Ping OK)' : 'nicht erreichbar'}
|
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'}
|
||||||
<Server size={18} className={theme === 'dark' ? 'text-slate-400' : 'text-slate-500'} />
|
/>
|
||||||
<div className="min-w-0">
|
<Server size={18} className="text-slate-400 dark:text-slate-500" />
|
||||||
<p className={`text-sm font-medium truncate ${theme === 'dark' ? 'text-slate-200' : 'text-slate-800'}`}>{w.name}</p>
|
<div className="min-w-0">
|
||||||
<p className={`text-xs ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>
|
<p className="text-sm font-medium truncate text-slate-900 dark:text-slate-200">{w.name}</p>
|
||||||
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
{w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
|
||||||
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
{w.info?.ip ? ` · IP ${w.info.ip}` : ''}
|
||||||
</p>
|
{w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
|
{w.encoders.map(e => (
|
||||||
|
<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}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!w.online && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
api.delete(`/workers/${encodeURIComponent(w.name)}`)
|
||||||
|
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
||||||
|
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
||||||
|
}}
|
||||||
|
title="Verwaisten Eintrag entfernen"
|
||||||
|
className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
))}
|
||||||
<div className="flex flex-wrap gap-1 justify-end">
|
</div>
|
||||||
{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'}`}>
|
</CardContent>
|
||||||
{ENCODER_LABELS[e] || e}
|
</Card>
|
||||||
</span>
|
|
||||||
))}
|
{/* Neue Maschine anbinden */}
|
||||||
</div>
|
<Card>
|
||||||
{!w.online && (
|
<CardHeader>
|
||||||
<button
|
<CardTitle>Maschine als Encoding-Worker anbinden</CardTitle>
|
||||||
onClick={() => {
|
</CardHeader>
|
||||||
api.delete(`/workers/${encodeURIComponent(w.name)}`)
|
<CardContent className="space-y-4">
|
||||||
.then(() => { toast('success', `„${w.name}" entfernt — ein aktiver Worker meldet sich binnen 1 min zurück`); laden() })
|
<div className="max-w-md">
|
||||||
.catch(() => toast('error', 'Entfernen fehlgeschlagen'))
|
<Input
|
||||||
}}
|
label="Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)"
|
||||||
title="Verwaisten Eintrag entfernen (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
|
value={rippyHost}
|
||||||
className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
|
onChange={e => setRippyHost(e.target.value.trim())}
|
||||||
>
|
placeholder="192.168.178.162"
|
||||||
<Trash2 size={14} />
|
/>
|
||||||
</button>
|
{siehtOeffentlichAus && (
|
||||||
)}
|
<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">
|
||||||
</div>
|
⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
|
||||||
</div>
|
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.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
||||||
|
<Button
|
||||||
|
key={wert}
|
||||||
|
variant={variante === wert ? 'amber' : 'secondary'}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVariante(wert)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Neue Maschine anbinden — zwei Varianten */}
|
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||||
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
|
{variante === 'linux'
|
||||||
<h3 className={`font-medium mb-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Maschine als Encoding-Worker anbinden</h3>
|
? '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.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/
|
<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">
|
||||||
Postgres — das geht nur im Heimnetz, nie über die externe Domain */}
|
{installBefehl}
|
||||||
<div className="mb-3">
|
|
||||||
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>
|
|
||||||
Rippy-Adresse aus Sicht des Workers (Heimnetz-IP oder -Name)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
value={rippyHost}
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
{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'}`}>
|
|
||||||
⚠️ 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
|
|
||||||
Proxy blockt den Installer-Download (401). Hier die <strong>LAN-IP</strong> der
|
|
||||||
Rippy-Maschine eintragen, z. B. 192.168.178.162.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2 mb-3">
|
|
||||||
{([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
|
|
||||||
<button
|
<button
|
||||||
key={wert}
|
onClick={kopieren}
|
||||||
onClick={() => setVariante(wert)}
|
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"
|
||||||
className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
|
title="Kopieren"
|
||||||
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}
|
{kopiert ? <CheckCircle size={15} className="text-emerald-400" /> : <Copy size={15} />}
|
||||||
</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'
|
Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier, wo das Laufwerk hängt.
|
||||||
? '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.'
|
</p>
|
||||||
: '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.'}
|
</CardContent>
|
||||||
</p>
|
</Card>
|
||||||
<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'}`}>
|
|
||||||
{installBefehl}
|
|
||||||
<button
|
|
||||||
onClick={kopieren}
|
|
||||||
className="absolute top-2 right-2 p-1.5 rounded bg-slate-700 text-slate-300 hover:bg-slate-600"
|
|
||||||
title="Kopieren"
|
|
||||||
>
|
|
||||||
{kopiert ? <CheckCircle size={14} className="text-emerald-400" /> : <Copy size={14} />}
|
|
||||||
</button>
|
|
||||||
</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,
|
|
||||||
wo das Laufwerk hängt. Voraussetzung: die Maschine erreicht diesen Rippy-Host im Netz.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+93
-126
@@ -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>
|
||||||
</div>
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Info</span>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.info}</p>
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.info}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
</CardContent>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
</Card>
|
||||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
</div>
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Warnungen</span>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.warning}</p>
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.warning}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
</CardContent>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
</Card>
|
||||||
<div className="p-2 rounded-lg bg-emerald-500/10 text-emerald-500">
|
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
</div>
|
<span className="text-xs text-slate-500 dark:text-slate-400 font-medium">Erfolge</span>
|
||||||
<p className={`text-2xl font-bold ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>{stats.success}</p>
|
<p className="text-2xl font-bold text-slate-900 dark:text-slate-100">{stats.success}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className={`rounded-lg p-4 border transition-colors duration-300 ${theme === 'dark' ? 'bg-slate-800 border-slate-700' : 'bg-white border-slate-200'}`}>
|
</CardContent>
|
||||||
<div className="flex items-center gap-3 mb-2">
|
</Card>
|
||||||
<div className="p-2 rounded-lg bg-rose-500/10 text-rose-500">
|
|
||||||
|
<Card>
|
||||||
|
<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>
|
||||||
<button
|
{['all', 'info', 'success', 'warning', 'error'].map(f => (
|
||||||
onClick={() => setFilter('all')}
|
<button
|
||||||
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
|
key={f}
|
||||||
filter === 'all'
|
onClick={() => setFilter(f)}
|
||||||
? 'bg-indigo-600 text-white'
|
className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
|
||||||
: theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
filter === f
|
||||||
}`}
|
? '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
|
}`}
|
||||||
</button>
|
>
|
||||||
<button
|
{f === 'all' ? 'Alle' : getLevelLabel(f)}
|
||||||
onClick={() => setFilter('info')}
|
</button>
|
||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user