-
-
-
Richtigen Titel wählen
-
- Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
-
-
-
-
+
+
+
+ Deine Wahl gilt für diese Disc — auch beim nächsten Einlegen.
+
-
-
-
+
+ setSuchbegriff(e.target.value)}
onKeyDown={e => e.key === 'Enter' && suchen()}
autoFocus
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'}`}
/>
-
-
- {fehler &&
{fehler}
}
-
- {kandidaten.length > 0 && (
-
- {kandidaten.map((k, i) => (
-
- ))}
-
- )}
+
+
+ {fehler &&
{fehler}
}
+
+ {kandidaten.length > 0 && (
+
+ {kandidaten.map((k, i) => (
+
+ ))}
+
+ )}
-
+
)
}
diff --git a/docker/ui/src/components/StorageMounts.tsx b/docker/ui/src/components/StorageMounts.tsx
index 582b9ee..2f4b74e 100644
--- a/docker/ui/src/components/StorageMounts.tsx
+++ b/docker/ui/src/components/StorageMounts.tsx
@@ -1,11 +1,10 @@
import { useState, useEffect } from 'react'
import { HardDrive, Plus, Trash2, CheckCircle, AlertCircle, RefreshCw, Folder, File, ArrowUp, FolderPlus } from 'lucide-react'
import { api } from '../lib/api'
-import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
-
-// Netzwerk-Speicherziele (NAS/PC-Freigaben) direkt aus dem UI einhängen —
-// jedes Ziel erscheint danach in der Ziel-Auswahl beim Rippen.
+import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
+import { Button } from './ui/Button'
+import { Input, Select } from './ui/Input'
interface Mount {
name: string
@@ -42,7 +41,6 @@ export default function StorageMounts() {
const [browseFiles, setBrowseFiles] = useState<{ name: string, size_mb: number | null }[]>([])
const [neuerOrdner, setNeuerOrdner] = useState('')
const [browserOffen, setBrowserOffen] = useState(false)
- const { theme } = useDarkMode()
const { toast } = useToast()
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 (
{/* Vorhandene Ziele */}
-
-
-
Verfügbare Ziele
-
- {targets.length === 0 ? (
-
Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.
- ) : (
-
- {targets.map(t => (
-
-
-
-
- {t.name}
-
- {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
-
-
-
- {mounts.some(m => m.name === t.name) && (
-
entfernen(t.name)} disabled={busy} className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10">
-
-
- )}
-
- ))}
-
- )}
-
-
- {/* 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.). */}
-
-
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'}`}
- >
-
-
- Ordner-Verwaltung (optional)
-
-
- 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.
-
-
-
- {browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
-
-
- {browserOffen && (<>
-
-
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"
- >
-
-
-
{browsePath}
-
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'}`}
- />
-
-
-
-
-
- {browseDirs.length === 0 && browseFiles.length === 0 ? (
-
Ordner ist leer
+
+
+
+ {targets.length === 0 ? (
+ Noch keine Unterordner in /app/media — sie entstehen beim ersten Rip oder Mount.
) : (
- <>
- {browseDirs.map(d => (
- browsen(d.path)}
- className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
- >
-
- {d.name}
-
- ))}
- {/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
- z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
- {browseFiles.map(f => (
-
-
-
{f.name}
- {f.size_mb != null && (
-
- {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
-
+
+ {targets.map(t => (
+
+
+
+
+ {t.name}
+
+ {t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
+
+
+
+ {mounts.some(m => m.name === t.name) && (
+
entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
+
+
)}
))}
- >
+
)}
-
- >)}
-
+
+
+
+ {/* Ordner-Verwaltung */}
+
+ 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"
+ >
+
+
+ Ordner-Verwaltung (optional)
+
+
+ Unterordner in der Ablage anschauen und neue anlegen — z. B. „anime" oder „doku".
+
+
+
+ {browserOffen ? 'zuklappen ▲' : 'aufklappen ▼'}
+
+
+
+ {browserOffen && (
+
+
+
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"
+ >
+
+
+
{browsePath}
+
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"
+ />
+
+
+
+
+
+ {browseDirs.length === 0 && browseFiles.length === 0 ? (
+
Ordner ist leer
+ ) : (
+ <>
+ {browseDirs.map(d => (
+
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"
+ >
+
+ {d.name}
+
+ ))}
+ {browseFiles.map(f => (
+
+
+ {f.name}
+ {f.size_mb != null && (
+
+ {f.size_mb >= 1024 ? `${(f.size_mb / 1024).toFixed(1)} GB` : `${f.size_mb} MB`}
+
+ )}
+
+ ))}
+ >
+ )}
+
+
+ )}
+
{/* Neues Netzwerk-Ziel */}
-
-
- Netzwerk-Ziel hinzufügen
-
-
-
-
- setName(e.target.value.toLowerCase())} className={feld} placeholder="nas-filme" />
-
-
+
+
+ {busy ? 'Hänge ein…' : 'Einhängen & speichern'}
+
+
+ {feedback && (
+
+ {feedback.startsWith('✓') ? : }
+ {feedback}
+
)}
-
-
- {busy ? 'Hänge ein…' : 'Einhängen & speichern'}
-
- {feedback && (
-
- {feedback.startsWith('✓') ? : }
- {feedback}
-
- )}
-
+
+
)
}
diff --git a/docker/ui/src/components/WorkerVerwaltung.tsx b/docker/ui/src/components/WorkerVerwaltung.tsx
index 6371785..3cbbd66 100644
--- a/docker/ui/src/components/WorkerVerwaltung.tsx
+++ b/docker/ui/src/components/WorkerVerwaltung.tsx
@@ -1,13 +1,10 @@
import { useState, useEffect } from 'react'
import { Server, RefreshCw, Copy, CheckCircle, Trash2 } from 'lucide-react'
import { api } from '../lib/api'
-import { useDarkMode } from '../context/ThemeContext'
import { useToast } from '../context/ToastContext'
-
-// Worker-Verwaltung (Commander-Anforderung 23.07.): Welche Maschinen encoden,
-// sind sie erreichbar, und wie bindet man eine neue an? Ein Worker "installiert
-// sich" über einen Copy-Paste-Befehl auf der Zielmaschine — danach taucht er
-// hier automatisch auf und zieht sich Kompressions-Jobs aus der Warteschlange.
+import { Card, CardHeader, CardTitle, CardContent } from './ui/Card'
+import { Button } from './ui/Button'
+import { Input } from './ui/Input'
interface WorkerInfo {
name: string
@@ -42,13 +39,8 @@ export default function WorkerVerwaltung() {
const [kopiert, setKopiert] = useState(false)
const [variante, setVariante] = useState<'linux' | 'windows'>('linux')
const [rippyHost, setRippyHost] = useState(window.location.hostname)
- const { theme } = useDarkMode()
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 =
!/^(\d{1,3}\.){3}\d{1,3}$/.test(rippyHost) &&
!rippyHost.endsWith('.local') &&
@@ -92,123 +84,127 @@ export default function WorkerVerwaltung() {
return (
{/* Verbundene Worker */}
-
-
-
Verbundene Worker
- 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'}`}>
+
+
+
+
+ Verbundene Worker
+
+ laden(true)} title="Worker neu laden">
-
-
- {workers.length === 0 ? (
-
Noch kein Worker gemeldet.
- ) : (
-
- {workers.map(w => (
-
-
-
-
-
-
{w.name}
-
- {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
- {w.info?.ip ? ` · IP ${w.info.ip}` : ''}
- {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
-
+
+
+
+ {workers.length === 0 ? (
+ Noch kein Worker gemeldet.
+ ) : (
+
+ {workers.map(w => (
+
+
+
+
+
+
{w.name}
+
+ {w.online ? 'online' : 'offline'} · Lebenszeichen {relativeZeit(w.last_seen)}
+ {w.info?.ip ? ` · IP ${w.info.ip}` : ''}
+ {w.info?.hostname && w.info.hostname !== w.name ? ` · ID ${w.info.hostname}` : ''}
+
+
+
+
+
+ {w.encoders.map(e => (
+
+ {ENCODER_LABELS[e] || e}
+
+ ))}
+
+ {!w.online && (
+
{
+ 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"
+ >
+
+
+ )}
-
-
- {w.encoders.map(e => (
-
- {ENCODER_LABELS[e] || e}
-
- ))}
-
- {!w.online && (
-
{
- 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 (alte Container-ID nach Rebuild) — aktive Worker melden sich automatisch wieder an"
- className="p-1.5 rounded text-rose-500 hover:bg-rose-500/10"
- >
-
-
- )}
-
-
+ ))}
+
+ )}
+
+
+
+ {/* Neue Maschine anbinden */}
+
+
+ Maschine als Encoding-Worker anbinden
+
+
+
+
setRippyHost(e.target.value.trim())}
+ placeholder="192.168.178.162"
+ />
+ {siehtOeffentlichAus && (
+
+ ⚠️ Das sieht nach einer externen Domain aus (Reverse-Proxy). Worker verbinden sich
+ direkt mit Redis/Postgres (Ports 6379/5432) — das geht nur im Heimnetz. Hier die LAN-IP eintragen, z. B. 192.168.178.162.
+
+ )}
+
+
+
+ {([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
+ setVariante(wert)}
+ >
+ {label}
+
))}
- )}
-
- {/* Neue Maschine anbinden — zwei Varianten */}
-
-
Maschine als Encoding-Worker anbinden
+
+ {variante === 'linux'
+ ? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
+ : '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.'}
+
- {/* Rippy-Adresse für die Befehle: Worker reden DIREKT mit Redis/
- Postgres — das geht nur im Heimnetz, nie über die externe Domain */}
-
-
-
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 && (
-
- ⚠️ 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 LAN-IP der
- Rippy-Maschine eintragen, z. B. 192.168.178.162.
-
- )}
-
-
-
- {([['linux', '🐧 Linux (Docker)'], ['windows', '🪟 Windows (nativ, ohne Docker)']] as const).map(([wert, label]) => (
+
+ {installBefehl}
setVariante(wert)}
- className={`px-3 py-1.5 rounded-lg text-xs font-medium border transition-all ${
- variante === wert
- ? theme === 'dark' ? 'bg-indigo-900/40 border-indigo-500 text-indigo-300' : 'bg-indigo-50 border-indigo-600 text-indigo-700'
- : theme === 'dark' ? 'bg-slate-800 border-slate-600 text-slate-400 hover:border-slate-500' : 'bg-white border-slate-300 text-slate-600 hover:border-slate-400'
- }`}
+ onClick={kopieren}
+ className="absolute top-3 right-3 p-1.5 rounded-lg bg-slate-800 text-slate-300 hover:bg-slate-700 hover:text-white transition-colors"
+ title="Kopieren"
>
- {label}
+ {kopiert ? : }
- ))}
-
-
- {variante === 'linux'
- ? 'Auf der Linux-Maschine (mit Docker) ausführen — WORKER_NAME frei wählen, unter dem Namen taucht sie oben auf und übernimmt Kompressions-Jobs. GPU wird erkannt und angezeigt.'
- : '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.'}
-
-
- {installBefehl}
-
- {kopiert ? : }
-
-
-
- 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.
-
-
+
+
+
+ Beide Varianten bedienen nur die Kompressions-Queue — gerippt wird immer hier, wo das Laufwerk hängt.
+
+
+
)
}
diff --git a/docker/ui/src/pages/Anleitung.tsx b/docker/ui/src/pages/Anleitung.tsx
index 878fa36..f52f282 100644
--- a/docker/ui/src/pages/Anleitung.tsx
+++ b/docker/ui/src/pages/Anleitung.tsx
@@ -1,37 +1,33 @@
import { ReactNode } from 'react'
-import { BookOpen, Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
-import { useDarkMode } from '../context/ThemeContext'
-
-// Anleitungs-Tab (Commander-Wunsch 24.07.): Rippy erklärt sich selbst —
-// keine externen Wikis, keine README-Suche. Reiner Text, keine API-Aufrufe.
+import { Disc, Tv, HardDrive, Bell, Wrench, Cpu, Download, HelpCircle } from 'lucide-react'
+import { PageHeader } from '../components/ui/PageHeader'
+import { Card, CardHeader, CardTitle, CardContent } from '../components/ui/Card'
function Abschnitt({ icon: Icon, titel, children }: { icon: any, titel: string, children: ReactNode }) {
- const { theme } = useDarkMode()
return (
-
-
-
- {titel}
-
-
+
+
+
+
+ {titel}
+
+
+
{children}
-
-
+
+
)
}
export default function AnleitungPage() {
- const { theme } = useDarkMode()
- const fett = theme === 'dark' ? 'text-slate-100 font-medium' : 'text-slate-900 font-medium'
+ const fett = "font-semibold text-slate-900 dark:text-slate-100"
return (
-
-
-
Anleitung
-
- So funktioniert Rippy — vom Einlegen bis zum fertigen Film in deinem Media-Server.
-
-
+
+
diff --git a/docker/ui/src/pages/Logs.tsx b/docker/ui/src/pages/Logs.tsx
index edbc1e2..22f4789 100644
--- a/docker/ui/src/pages/Logs.tsx
+++ b/docker/ui/src/pages/Logs.tsx
@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'
import { Terminal, AlertCircle, CheckCircle, RefreshCw } from 'lucide-react'
import { api } from '../lib/api'
-import { useDarkMode } from '../context/ThemeContext'
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 {
id: string
@@ -18,12 +20,9 @@ export default function LogsPage() {
const [filter, setFilter] = useState('all')
const [loading, setLoading] = useState(true)
const [refreshing, setRefreshing] = useState(false)
- const { theme } = useDarkMode()
const { toast } = useToast()
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)
try {
const response = await api.get('/logs', { params: { limit: 200 } })
@@ -46,19 +45,19 @@ export default function LogsPage() {
const getLevelColor = (level: string) => {
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 '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 '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'
- 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'
+ case 'error': return 'bg-rose-500/15 text-rose-600 dark:text-rose-400 border-rose-500/30'
+ case 'warning': return 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30'
+ case 'success': return 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border-emerald-500/30'
+ default: return 'bg-sky-500/15 text-sky-600 dark:text-sky-400 border-sky-500/30'
}
}
const getLevelIcon = (level: string) => {
switch (level) {
- case 'error': return
- case 'warning': return
- case 'success': return
- default: return
+ case 'error': return
+ case 'warning': return
+ case 'success': return
+ default: return
}
}
@@ -83,153 +82,121 @@ export default function LogsPage() {
}
return (
-
- {/* Header */}
-
-
Logs
-
Alle relevanten Log-Informationen des Ripping-Systems
-
+
+
refreshLogs(true)}>
+
+ Neu laden
+
+ }
+ />
- {/* Stats */}
-
-
-
-
+ {/* Stats Grid */}
+
-
{stats.info}
-
-
-
-
+
+
+
+
+
+
+
- Warnung
-
-
{stats.warning}
-
-
-
-
+
+
Warnungen
+
{stats.warning}
+
+
+
+
+
+
+
- Erfolg
-
-
{stats.success}
-
-
-
-
+
+
Erfolge
+
{stats.success}
+
+
+
+
+
+
+
- Fehler
-
-
{stats.error}
-
+
+
Fehler
+
{stats.error}
+
+
+
- {/* Filters */}
-
-
-
Filter:
-
setFilter('all')}
- className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
- filter === 'all'
- ? 'bg-indigo-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'
- }`}
- >
- Alle
-
-
setFilter('info')}
- className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors ${
- filter === 'info'
- ? 'bg-blue-600 text-white'
- : theme === 'dark' ? 'bg-slate-700 text-slate-300 hover:bg-slate-600' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
- }`}
- >
- Info
-
-
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
-
-
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
-
-
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
-
-
-
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'}`}
- >
-
- Neu laden
-
+ {/* Filters & Log List */}
+
+
+ Filter:
+ {['all', 'info', 'success', 'warning', 'error'].map(f => (
+ setFilter(f)}
+ className={`px-3 py-1 rounded-full text-xs font-medium transition-all ${
+ 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'
+ }`}
+ >
+ {f === 'all' ? 'Alle' : getLevelLabel(f)}
+
+ ))}
-
- {/* Logs List */}
-
{filteredLogs.length === 0 ? (
-
-
-
+
+
+
-
Keine Logs gefunden
+
Keine Logs für diesen Filter vorhanden
) : (
-
+
{filteredLogs.map(log => (
-
+
-
+
{getLevelIcon(log.level)}
-
+
{log.source}
-
+
{log.timestamp}
-
+
{getLevelLabel(log.level)}
-
+
{log.message}
{log.details && (
-
+
{log.details}
)}
@@ -240,7 +207,7 @@ export default function LogsPage() {
)}
-
+
)
}