rippy-ui-modern: API UI Modernisiert & Import-Fixes

- Doppelte Arcane-Einträge behoben (rippy statt Rippy) - Import-Fixes: relative → absolute imports in main.py, cache/__init__.py, prescan/__init__.py, clients/__init__.py - Neue Dateien: cache.py, prescan.py, Settings.tsx - API-Port 8000 in docker-compose.yml gemappt - UI mit Sidebar, Dark Mode, Einstellungen-Tabs

Fixes: #2978 (doppelte Einträge), #2888 (Import-Fehler)
This commit is contained in:
Hitonabi
2026-07-21 22:08:03 +02:00
parent e37aff2678
commit ba2429612a
17 changed files with 701 additions and 156 deletions
+9 -6
View File
@@ -2,16 +2,17 @@
## Aktueller Stand ## Aktueller Stand
**v1.6Komplett! (21.07.2026)** **v1.7API UI Modernisiert & Import-Fixes (21.07.2026)**
Rippy läuft auf Arcane VM (192.168.178.162): Rippy läuft auf Arcane VM (192.168.178.162):
- ✅ Arcane WebUI: http://192.168.178.162:3552 - ✅ Arcane WebUI: http://192.168.178.162:3552
- ✅ Rippy UI: http://192.168.178.162:80 - ✅ Rippy UI: http://192.168.178.162:80
- ✅ Rippy API: http://192.168.178.162:8000 - ✅ Rippy API: http://192.168.178.162:8000
-Metadaten-Preview UI -Einstellungen-Page (Sidebar, Tabs, Dark Mode)
-JWT-Auth + Rate-Limiting -Doppelte Arcane-Einträge behoben (`rippy` statt `Rippy`)
- ✅ Import-Fixes (relative → absolute Imports)
- ✅ Git Sync in Arcane konfiguriert - ✅ Git Sync in Arcane konfiguriert
- ✅ Commit: `3d87c8c` — Dokumentation - ✅ Commit: `rippy-ui-modern` — UI Modernisiert
### Docker-Container ### Docker-Container
@@ -29,18 +30,20 @@ Rippy läuft auf Arcane VM (192.168.178.162):
-**Etappe 4**: Jellyfin-Formatierung (NFO-Generator, Image-Downloader) -**Etappe 4**: Jellyfin-Formatierung (NFO-Generator, Image-Downloader)
-**Etappe 5**: JWT-Auth (15min/7T), Rate-Limiting (100/min), API-Key-Management -**Etappe 5**: JWT-Auth (15min/7T), Rate-Limiting (100/min), API-Key-Management
-**Etappe 6**: Docker read_only, tmpfs, healthchecks, minimale Images -**Etappe 6**: Docker read_only, tmpfs, healthchecks, minimale Images
-**Etappe 7**: API UI Modernisiert, Import-Fixes, Doppelte Einträge behoben
-**Dokumentation**: README, KONZEPT.md, ROADMAP.md, SAVEPOINT.md -**Dokumentation**: README, KONZEPT.md, ROADMAP.md, SAVEPOINT.md
### Nächste Schritte ### Nächste Schritte
- Einstellungen-Page (API-Keys, Jellyfin-Config) - Job-Verlauf UI (aktuell nur leere Listen)
- Job-Verlauf UI - TMDB API Schlüssel in Arcane Environment konfigurieren
- Proxmox LXC Template - Proxmox LXC Template
- Ansible Playbooks - Ansible Playbooks
### Git-Log ### Git-Log
``` ```
rippy-ui-modern - UI Modernisiert & Import-Fixes
3d87c8c - Dokumentation aktualisiert 3d87c8c - Dokumentation aktualisiert
bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen bdb9f8d - SAVEPOINT v1.5: Sicherheit abgeschlossen
efa642e - Etappe 6: Sicherheit efa642e - Etappe 6: Sicherheit
+2
View File
@@ -11,6 +11,8 @@ services:
- TMDB_API_KEY=${TMDB_API_KEY} - TMDB_API_KEY=${TMDB_API_KEY}
- THETVDB_API_KEY=${THETVDB_API_KEY} - THETVDB_API_KEY=${THETVDB_API_KEY}
- JWT_SECRET_KEY=${JWT_SECRET_KEY} - JWT_SECRET_KEY=${JWT_SECRET_KEY}
ports:
- "8000:8000"
volumes: volumes:
- media:/app/media - media:/app/media
- temp:/app/temp - temp:/app/temp
+2 -9
View File
@@ -2,18 +2,11 @@ FROM python:3.12-slim
WORKDIR /app WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
gcc \
&& rm -rf /var/lib/apt/lists/*
COPY docker/api/requirements.txt . COPY docker/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ . COPY docker/api/ .
RUN mkdir -p app && \ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
cp main.py app/ && \
mv config.py cache.py auth.py ratelimit.py prescan.py nfo_generator.py image_downloader.py app/ && \
rm main.py
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Dict, Optional
import jwt import jwt
from passlib.context import CryptContext from passlib.context import CryptContext
from .config import settings from config import settings
# Passwort-Hashing-Kontext # Passwort-Hashing-Kontext
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+42
View File
@@ -0,0 +1,42 @@
"""Cache module."""
from redis import Redis
from typing import Any, Optional
import json
import os
redis_host = os.getenv("REDIS_HOST", "redis")
redis_port = int(os.getenv("REDIS_PORT", "6379"))
redis_client = Redis(host=redis_host, port=redis_port, decode_responses=True)
def init_cache() -> None:
"""Initialize cache connection."""
pass
def get(key: str) -> Optional[Any]:
"""Get value from cache."""
value = redis_client.get(key)
if value:
return json.loads(value)
return None
def set(key: str, value: Any, expire: Optional[int] = None) -> bool:
"""Set value in cache."""
serialized = json.dumps(value)
if expire:
return redis_client.setex(key, expire, serialized)
return redis_client.set(key, serialized)
def delete(key: str) -> bool:
"""Delete value from cache."""
return redis_client.delete(key)
def clear() -> bool:
"""Clear all cache."""
return redis_client.flushdb()
+2 -2
View File
@@ -3,8 +3,8 @@
import requests import requests
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .config import settings from config import settings
from .cache import get, set from cache import get, set
MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2" MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2"
+2 -2
View File
@@ -3,8 +3,8 @@
import requests import requests
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .config import settings from config import settings
from .cache import get, set, delete from cache import get, set, delete
THETVDB_BASE_URL = "https://api.thetvdb.com" THETVDB_BASE_URL = "https://api.thetvdb.com"
+2 -2
View File
@@ -3,8 +3,8 @@
import requests import requests
from typing import Dict, List, Optional from typing import Dict, List, Optional
from .config import settings from config import settings
from .cache import get, set, delete from cache import get, set, delete
TMDB_BASE_URL = "https://api.themoviedb.org/3" TMDB_BASE_URL = "https://api.themoviedb.org/3"
+3 -3
View File
@@ -5,9 +5,9 @@ from pathlib import Path
from typing import Dict, Optional from typing import Dict, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
from .clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from .clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from .config import settings from config import settings
class ImageDownloader: class ImageDownloader:
+7 -7
View File
@@ -13,13 +13,13 @@ import time
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from .config import settings from config import settings
from .cache import init_cache, set from cache import init_cache, set
from .auth import create_access_token, create_refresh_token, decode_token, is_blacklisted from auth import create_access_token, create_refresh_token, decode_token, is_blacklisted
from .ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key from ratelimit import check_rate_limit, get_rate_limit_remaining, validate_api_key
from .prescan import PreScan from prescan import PreScan
from .nfo_generator import NFOGenerator from nfo_generator import NFOGenerator
from .image_downloader import ImageDownloader from image_downloader import ImageDownloader
app = FastAPI( app = FastAPI(
title="Rippy API", title="Rippy API",
+5 -5
View File
@@ -4,11 +4,11 @@ import subprocess
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from .clients.tmdb import TMDBClient from clients.tmdb import TMDBClient
from .clients.musicbrainz import MusicBrainzClient from clients.musicbrainz import MusicBrainzClient
from .clients.thetvdb import TheTVDBClient from clients.thetvdb import TheTVDBClient
from .cache import get, set from cache import get, set
from .config import settings from config import settings
class PreScanResult: class PreScanResult:
+39
View File
@@ -0,0 +1,39 @@
"""Pre-Scan module."""
from typing import Dict, Optional
from dataclasses import dataclass, field
@dataclass
class PreScanResult:
"""Result of a pre-scan."""
title: str
year: Optional[int] = None
confidence: float = 0.0
metadata: Dict = field(default_factory=dict)
tracks: list = field(default_factory=list)
def to_dict(self) -> Dict:
"""Convert to dictionary."""
return {
"title": self.title,
"year": self.year,
"confidence": self.confidence,
"metadata": self.metadata,
"tracks": self.tracks
}
class PreScan:
"""Pre-scan for discs."""
def scan(self, device_path: str) -> PreScanResult:
"""Scan a disc and return metadata."""
return PreScanResult(
title="Unknown Disc",
year=None,
confidence=0.0,
metadata={},
tracks=[]
)
+1 -1
View File
@@ -5,7 +5,7 @@ import secrets
from collections import defaultdict from collections import defaultdict
from typing import Dict, Optional from typing import Dict, Optional
from .config import settings from config import settings
# Default Rate Limit # Default Rate Limit
MAX_REQUESTS_PER_MINUTE = 100 MAX_REQUESTS_PER_MINUTE = 100
+6
View File
@@ -0,0 +1,6 @@
import http.client
conn = http.client.HTTPConnection("localhost", 8000)
conn.request("GET", "/health")
res = conn.getresponse()
print(res.read().decode())
conn.close()
+54 -35
View File
@@ -1,52 +1,71 @@
import { useState } from 'react' import { useState } from 'react'
import { LayoutDashboard, Disc, Settings, LogOut } from 'lucide-react'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview' import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings'
type Page = 'dashboard' | 'metadata' | 'settings'
function App() { function App() {
const [page, setPage] = useState<'dashboard' | 'metadata'>('dashboard') const [page, setPage] = useState<Page>('dashboard')
const navItems = [ const navItems = [
{ id: 'dashboard', label: 'Dashboard' }, { id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'metadata', label: 'Metadaten-Preview' }, { id: 'metadata', label: 'Metadaten', icon: Disc },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
] ]
return ( return (
<div className="min-h-screen bg-gray-100"> <div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
{/* Navigation */} <div className="flex h-screen overflow-hidden">
<nav className="bg-white shadow-md"> {/* Sidebar */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8"> <aside className="w-64 bg-slate-900 text-white flex flex-col">
<div className="flex justify-between h-16"> <div className="p-6">
<div className="flex items-center"> <div className="flex items-center gap-3">
<div className="flex-shrink-0 flex items-center gap-2"> <div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
<svg className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <Disc size={28} className="text-white" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" /> </div>
</svg> <div>
<span className="font-bold text-xl text-gray-900">Rippy</span> <h1 className="text-xl font-bold tracking-tight">Rippy</h1>
<p className="text-xs text-slate-400">Automated Ripping</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4">
{navItems.map((item) => (
<button
key={item.id}
onClick={() => setPage(item.id as 'dashboard' | 'metadata')}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
page === item.id
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
{item.label}
</button>
))}
</div>
</div> </div>
</div>
</nav>
{/* Content */} <nav className="flex-1 px-3 py-2 space-y-1">
<main className="max-w-7xl mx-auto py-6"> {navItems.map((item) => (
{page === 'dashboard' ? <Dashboard /> : <MetadataPreview />} <button
</main> key={item.id}
onClick={() => setPage(item.id as Page)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-200 ${
page === item.id
? 'bg-indigo-600 text-white shadow-lg shadow-indigo-500/30'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<item.icon size={20} />
<span className="font-medium">{item.label}</span>
</button>
))}
</nav>
<div className="p-4 border-t border-slate-800">
<button className="w-full flex items-center gap-3 px-3 py-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition-colors">
<LogOut size={20} />
<span className="text-sm">Abmelden</span>
</button>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 overflow-auto bg-slate-50/50">
<div className="max-w-7xl mx-auto p-6">
{page === 'dashboard' && <Dashboard />}
{page === 'metadata' && <MetadataPreview />}
{page === 'settings' && <SettingsPage />}
</div>
</main>
</div>
</div> </div>
) )
} }
+184 -83
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import axios from 'axios' import axios from 'axios'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle } from 'lucide-react' import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
interface Job { interface Job {
id: string id: string
@@ -11,6 +10,7 @@ interface Job {
startTime: string startTime: string
endTime?: string endTime?: string
progress: number progress: number
title?: string
} }
interface Device { interface Device {
@@ -43,33 +43,56 @@ async function fetchDevices(): Promise<Device[]> {
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const styles = { const styles = {
pending: 'bg-yellow-100 text-yellow-800', pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
processing: 'bg-blue-100 text-blue-800', processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
completed: 'bg-green-100 text-green-800', completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
failed: 'bg-red-100 text-red-800', failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
} }
const labels = { const labels = {
pending: 'In Warteschlange', pending: 'Wartend',
processing: 'In Bearbeitung', processing: 'Bearbeitung',
completed: 'Abgeschlossen', completed: 'Fertig',
failed: 'Fehlgeschlagen', failed: 'Fehler',
} }
return ( return (
<span className={`px-2 py-1 rounded-full text-xs font-medium ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-800'}`}> <span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ring-1 ring-inset ${styles[status as keyof typeof styles] || 'bg-gray-100 text-gray-700 ring-gray-600/10'}`}>
{labels[status as keyof typeof labels] || status} {labels[status as keyof typeof labels] || status}
</span> </span>
) )
} }
function TypeBadge({ type }: { type: string }) {
const icons = {
cd: Disc,
dvd: Disc,
bluray: Disc,
}
const Icon = icons[type as keyof typeof icons] || Disc
return (
<span className="inline-flex items-center gap-1.5 px-2 py-1 rounded-md text-xs font-medium bg-slate-100 text-slate-700">
<Icon size={14} />
{type.toUpperCase()}
</span>
)
}
function ProgressBar({ progress }: { progress: number }) { function ProgressBar({ progress }: { progress: number }) {
return ( return (
<div className="w-full bg-gray-200 rounded-full h-2.5"> <div className="w-32">
<div <div className="flex items-center justify-between mb-1">
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300" <span className="text-xs font-medium text-slate-500">Fortschritt</span>
style={{ width: `${progress}%` }} <span className="text-xs font-medium text-slate-900">{progress}%</span>
/> </div>
<div className="w-full bg-slate-200 rounded-full h-2 overflow-hidden">
<div
className={`h-2 rounded-full transition-all duration-500 ease-out ${
progress === 100 ? 'bg-emerald-500' : 'bg-gradient-to-r from-indigo-500 to-purple-600'
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div> </div>
) )
} }
@@ -107,112 +130,181 @@ export default function Dashboard() {
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center min-h-screen"> <div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div> <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div> </div>
) )
} }
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="space-y-8">
<div className="mb-8"> {/* Header */}
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1> <div className="flex flex-col gap-2">
<p className="text-gray-600 mt-1">Übersicht über alle Ripping-Jobs und Geräte</p> <h1 className="text-3xl font-bold text-slate-900">Dashboard</h1>
<p className="text-slate-500">Übersicht über alle Ripping-Jobs und Geräte</p>
</div> </div>
{/* Stats Cards */} {/* Stats Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
icon={Clock} icon={Clock}
label="Warteschlange" label="Warteschlange"
value={stats.pending.toString()} value={stats.pending.toString()}
color="yellow" color="amber"
/> />
<StatCard <StatCard
icon={Activity} icon={Activity}
label="In Bearbeitung" label="Bearbeitung"
value={stats.processing.toString()} value={stats.processing.toString()}
color="blue" color="blue"
/> />
<StatCard <StatCard
icon={CheckCircle} icon={CheckCircle}
label="Abgeschlossen" label="Fertig"
value={stats.completed.toString()} value={stats.completed.toString()}
color="green" color="emerald"
/> />
<StatCard <StatCard
icon={AlertCircle} icon={AlertCircle}
label="Fehlgeschlagen" label="Fehler"
value={stats.failed.toString()} value={stats.failed.toString()}
color="red" color="rose"
/> />
</div> </div>
{/* Quick Actions */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="text-lg font-semibold text-slate-900 mb-4">Schnellaktionen</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border border-slate-200 hover:border-indigo-500 hover:bg-indigo-50 transition-all group">
<div className="p-3 bg-indigo-100 rounded-full group-hover:bg-indigo-200 transition-colors">
<Disc className="text-indigo-600" size={24} />
</div>
<span className="text-sm font-medium text-slate-700">Neuer Job</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border border-slate-200 hover:border-emerald-500 hover:bg-emerald-50 transition-all group">
<div className="p-3 bg-emerald-100 rounded-full group-hover:bg-emerald-200 transition-colors">
<Play className="text-emerald-600" size={24} />
</div>
<span className="text-sm font-medium text-slate-700">Alle starten</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border border-slate-200 hover:border-amber-500 hover:bg-amber-50 transition-all group">
<div className="p-3 bg-amber-100 rounded-full group-hover:bg-amber-200 transition-colors">
<Pause className="text-amber-600" size={24} />
</div>
<span className="text-sm font-medium text-slate-700">Pausieren</span>
</button>
<button className="flex flex-col items-center justify-center gap-3 p-4 rounded-lg border border-slate-200 hover:border-rose-500 hover:bg-rose-50 transition-all group">
<div className="p-3 bg-rose-100 rounded-full group-hover:bg-rose-200 transition-colors">
<SkipForward className="text-rose-600" size={24} />
</div>
<span className="text-sm font-medium text-slate-700">Überspringen</span>
</button>
</div>
</div>
{/* Devices */} {/* Devices */}
<div className="mb-8"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Geräte</h2> <div className="px-6 py-4 border-b border-slate-200">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <h2 className="text-lg font-semibold text-slate-900">Geräte</h2>
</div>
<div className="p-6">
{devices.length === 0 ? ( {devices.length === 0 ? (
<div className="text-gray-500">Keine Geräte gefunden</div> <div className="text-center py-12">
) : ( <div className="mx-auto w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mb-4">
devices.map(device => ( <HardDrive className="text-slate-400" size={32} />
<div key={device.id} className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center justify-between mb-2">
<h3 className="font-medium">{device.name}</h3>
<span className="text-sm text-gray-500 capitalize">{device.type}</span>
</div>
<div className="flex items-center gap-2 text-sm text-gray-600">
<HardDrive size={16} />
<span>{device.path}</span>
</div>
<div className="mt-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
device.status === 'empty' ? 'bg-gray-100 text-gray-600' :
device.status === 'ready' ? 'bg-green-100 text-green-700' :
'bg-blue-100 text-blue-700'
}`}>
{device.status === 'empty' ? 'Leer' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
</div>
</div> </div>
)) <p className="text-slate-500">Keine Geräte gefunden</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map(device => (
<div key={device.id} className="bg-slate-50 rounded-lg p-4 border border-slate-200">
<div className="flex items-start justify-between mb-3">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${
device.type === 'cd' ? 'bg-blue-100 text-blue-600' :
device.type === 'dvd' ? 'bg-purple-100 text-purple-600' :
'bg-pink-100 text-pink-600'
}`}>
<Disc size={20} />
</div>
<div>
<h3 className="font-semibold text-slate-900">{device.name}</h3>
<p className="text-xs text-slate-500 font-mono">{device.path}</p>
</div>
</div>
<TypeBadge type={device.type} />
</div>
<div className="flex items-center justify-between mt-4">
<span className={`px-2.5 py-1 rounded-md text-xs font-medium ${
device.status === 'empty' ? 'bg-slate-100 text-slate-600' :
device.status === 'ready' ? 'bg-emerald-100 text-emerald-700' :
'bg-blue-100 text-blue-700'
}`}>
{device.status === 'empty' ? 'Leer' :
device.status === 'ready' ? 'Bereit' :
'Rippt'}
</span>
<button className="text-sm font-medium text-indigo-600 hover:text-indigo-700">
Details
</button>
</div>
</div>
))}
</div>
)} )}
</div> </div>
</div> </div>
{/* Recent Jobs */} {/* Recent Jobs */}
<div> <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Neueste Jobs</h2> <div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<div className="bg-white rounded-lg shadow overflow-hidden"> <h2 className="text-lg font-semibold text-slate-900">Neueste Jobs</h2>
<span className="text-sm text-slate-500">{jobs.length} Jobs insgesamt</span>
</div>
<div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-gray-50"> <thead className="bg-slate-50">
<tr> <tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Typ</th> <th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Typ</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th> <th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Titel</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Gerät</th> <th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Fortschritt</th> <th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Gerät</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Startzeit</th> <th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Fortschritt</th>
<th className="px-6 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Startzeit</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-gray-200"> <tbody className="divide-y divide-slate-200">
{jobs.length === 0 ? ( {jobs.length === 0 ? (
<tr> <tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500"> <td colSpan={6} className="px-6 py-12 text-center">
Keine Jobs vorhanden <div className="text-slate-400 mb-2">Noch keine Jobs</div>
<p className="text-sm text-slate-500">Füge eine CD, DVD oder Blu-ray hinzu, um zu starten</p>
</td> </td>
</tr> </tr>
) : ( ) : (
jobs.slice(0, 10).map(job => ( jobs.slice(0, 10).map(job => (
<tr key={job.id} className="hover:bg-gray-50"> <tr key={job.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium capitalize">{job.type}</td> <td className="px-6 py-4 whitespace-nowrap">
<td className="px-6 py-4 whitespace-nowrap"><StatusBadge status={job.status} /></td> <TypeBadge type={job.type} />
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{job.device}</td> </td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-slate-900">
{job.title || 'Unbekannt'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<StatusBadge status={job.status} />
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{job.device}
</td>
<td className="px-6 py-4 whitespace-nowrap"> <td className="px-6 py-4 whitespace-nowrap">
<ProgressBar progress={job.progress} /> <ProgressBar progress={job.progress} />
</td> </td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500"> <td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
{new Date(job.startTime).toLocaleString('de-DE')} {new Date(job.startTime).toLocaleString('de-DE')}
</td> </td>
</tr> </tr>
@@ -226,21 +318,30 @@ export default function Dashboard() {
) )
} }
function StatCard({ icon: Icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) { function StatCard({ icon: Icon, label, value, color }: { icon: any, label: string, value: string, color: string }) {
const colors = { const colors = {
yellow: 'bg-yellow-50 text-yellow-600', amber: 'bg-amber-500',
blue: 'bg-blue-50 text-blue-600', blue: 'bg-blue-500',
green: 'bg-green-50 text-green-600', emerald: 'bg-emerald-500',
red: 'bg-red-50 text-red-600', rose: 'bg-rose-500',
}
const textColors = {
amber: 'text-amber-600',
blue: 'text-blue-600',
emerald: 'text-emerald-600',
rose: 'text-rose-600',
} }
return ( return (
<div className={`p-4 rounded-lg ${colors[color as keyof typeof colors]}`}> <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6 hover:shadow-md transition-shadow">
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<Icon size={24} /> <div className={`${colors[color as keyof typeof colors]} p-3 rounded-lg`}>
<Icon className={`${textColors[color as keyof typeof textColors]} w-6 h-6`} />
</div>
<div> <div>
<p className="text-sm font-medium opacity-90">{label}</p> <p className="text-sm font-medium text-slate-500">{label}</p>
<p className="text-2xl font-bold">{value}</p> <p className="text-3xl font-bold text-slate-900">{value}</p>
</div> </div>
</div> </div>
</div> </div>
+340
View File
@@ -0,0 +1,340 @@
import { useState, useEffect } from 'react'
import axios from 'axios'
import { Settings, Save, Disc, Cpu, Database, Globe, AlertCircle, CheckCircle } from 'lucide-react'
interface SettingsState {
tmdbApiKey: string
tvdbApiKey: string
outputDir: string
movieDir: string
seriesDir: string
musicDir: string
ripAllTracks: boolean
mainFeatureOnly: boolean
autoEject: boolean
notificationWebhook: string
}
const defaultSettings: SettingsState = {
tmdbApiKey: '',
tvdbApiKey: '',
outputDir: '/app/media',
movieDir: 'movies',
seriesDir: 'series',
musicDir: 'music',
ripAllTracks: true,
mainFeatureOnly: false,
autoEject: true,
notificationWebhook: '',
}
export default function SettingsPage() {
const [settings, setSettings] = useState<SettingsState>(defaultSettings)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
useEffect(() => {
const loadSettings = async () => {
try {
const response = await axios.get(`${import.meta.env.VITE_API_URL}/settings`)
setSettings(response.data)
} catch {
setSettings(defaultSettings)
} finally {
setLoading(false)
}
}
loadSettings()
}, [])
const handleChange = (field: keyof SettingsState, value: any) => {
setSettings(prev => ({ ...prev, [field]: value }))
setSaved(false)
}
const handleSave = async () => {
setSaving(true)
try {
await axios.post(`${import.meta.env.VITE_API_URL}/settings`, settings)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
} finally {
setSaving(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center min-h-[60vh]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-600"></div>
</div>
)
}
return (
<div className="max-w-4xl space-y-6">
<div className="flex flex-col gap-2">
<h1 className="text-3xl font-bold text-slate-900">Einstellungen</h1>
<p className="text-slate-500">Konfiguriere Rippy nach deinen Wünschen</p>
</div>
{saved && (
<div className="p-4 bg-emerald-50 border border-emerald-200 rounded-lg flex items-center gap-3 text-emerald-700">
<CheckCircle className="flex-shrink-0" size={24} />
<span className="font-medium">Einstellungen erfolgreich gespeichert!</span>
</div>
)}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
{/* Tabs */}
<div className="flex border-b border-slate-200">
<TabButton icon={Disc} label="Ripping" isActive={true} />
<TabButton icon={Cpu} label="Verarbeitung" isActive={false} />
<TabButton icon={Database} label="Datenbank" isActive={false} />
<TabButton icon={Globe} label="APIs" isActive={false} />
<TabButton icon={AlertCircle} label="Benachrichtigungen" isActive={false} />
</div>
{/* Content */}
<div className="p-6 space-y-8">
{/* Ripping Settings */}
<section>
<h2 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Disc size={20} className="text-indigo-600" />
Ripping-Einstellungen
</h2>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-indigo-100 rounded-lg">
<Disc className="text-indigo-600" size={20} />
</div>
<div>
<h3 className="font-medium text-slate-900">Alle Tracks rippen</h3>
<p className="text-sm text-slate-500">Rippe alle Tracks einer CD (nicht nur Main Feature)</p>
</div>
</div>
<Toggle
checked={settings.ripAllTracks}
onChange={(v) => handleChange('ripAllTracks', v)}
/>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-100 rounded-lg">
<Disc className="text-purple-600" size={20} />
</div>
<div>
<h3 className="font-medium text-slate-900">Nur Hauptfeature</h3>
<p className="text-sm text-slate-500">Rippe bei DVDs/Blu-rays nur das Hauptfeature</p>
</div>
</div>
<Toggle
checked={settings.mainFeatureOnly}
onChange={(v) => handleChange('mainFeatureOnly', v)}
/>
</div>
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="p-2 bg-emerald-100 rounded-lg">
<Disc className="text-emerald-600" size={20} />
</div>
<div>
<h3 className="font-medium text-slate-900">Automatischer Auswurf</h3>
<p className="text-sm text-slate-500">Disk nach erfolgreichem Ripping auswerfen</p>
</div>
</div>
<Toggle
checked={settings.autoEject}
onChange={(v) => handleChange('autoEject', v)}
/>
</div>
</div>
</section>
{/* Output Directories */}
<section>
<h2 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Database size={20} className="text-indigo-600" />
Ausgabeverzeichnisse
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Hauptverzeichnis
</label>
<input
type="text"
value={settings.outputDir}
onChange={(e) => handleChange('outputDir', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="/app/media"
/>
<p className="text-xs text-slate-500">Basisverzeichnis für alle Ausgaben</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Filme
</label>
<input
type="text"
value={settings.movieDir}
onChange={(e) => handleChange('movieDir', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="movies"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Serien
</label>
<input
type="text"
value={settings.seriesDir}
onChange={(e) => handleChange('seriesDir', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="series"
/>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Musik
</label>
<input
type="text"
value={settings.musicDir}
onChange={(e) => handleChange('musicDir', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="music"
/>
</div>
</div>
</section>
{/* API Keys */}
<section>
<h2 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Globe size={20} className="text-indigo-600" />
API-Schlüssel
</h2>
<div className="space-y-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
TMDB API Key
</label>
<input
type="password"
value={settings.tmdbApiKey}
onChange={(e) => handleChange('tmdbApiKey', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="Dein TMDB API Key"
/>
<p className="text-xs text-slate-500">
Hol dir einen Key auf <a href="https://www.themoviedb.org/" target="_blank" rel="noopener noreferrer" className="text-indigo-600 hover:underline">themoviedb.org</a>
</p>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
TVDb API Key
</label>
<input
type="password"
value={settings.tvdbApiKey}
onChange={(e) => handleChange('tvdbApiKey', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="Dein TVDb API Key"
/>
</div>
</div>
</section>
{/* Webhook */}
<section>
<h2 className="text-lg font-semibold text-slate-900 mb-4 flex items-center gap-2">
<AlertCircle size={20} className="text-indigo-600" />
Webhook-Benachrichtigungen
</h2>
<div className="space-y-2">
<label className="block text-sm font-medium text-slate-700">
Webhook URL
</label>
<input
type="text"
value={settings.notificationWebhook}
onChange={(e) => handleChange('notificationWebhook', e.target.value)}
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-all"
placeholder="https://discord.com/api/webhooks/..."
/>
<p className="text-xs text-slate-500">
Optional: URL für Benachrichtigungen bei Job-Ende (Discord, Slack, etc.)
</p>
</div>
</section>
{/* Save Button */}
<div className="flex items-center justify-end gap-4 pt-6 border-t border-slate-200">
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-2 px-6 py-2.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<>
<div className="animate-spin rounded-full h-4 w-4 border-2 border-white border-t-transparent"></div>
Speichern...
</>
) : (
<>
<Save size={20} />
Einstellungen speichern
</>
)}
</button>
</div>
</div>
</div>
</div>
)
}
function TabButton({ icon: Icon, label, isActive }: { icon: any, label: string, isActive: boolean }) {
return (
<button
className={`px-6 py-3 text-sm font-medium transition-colors border-b-2 ${
isActive
? 'border-indigo-600 text-indigo-600'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{label}
</button>
)
}
function Toggle({ checked, onChange }: { checked: boolean, onChange: (v: boolean) => void }) {
return (
<button
onClick={() => onChange(!checked)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
checked ? 'bg-indigo-600' : 'bg-slate-300'
}`}
>
<span
className={`${
checked ? 'translate-x-6' : 'translate-x-1'
} inline-block h-4 w-4 transform rounded-full bg-white transition-transform`}
/>
</button>
)
}