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
+2 -9
View File
@@ -2,18 +2,11 @@ FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
&& rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y --no-install-recommends gcc && rm -rf /var/lib/apt/lists/*
COPY docker/api/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY docker/api/ .
RUN mkdir -p 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"]
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--app-dir", "/app"]
+1 -1
View File
@@ -8,7 +8,7 @@ from typing import Dict, Optional
import jwt
from passlib.context import CryptContext
from .config import settings
from config import settings
# Passwort-Hashing-Kontext
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
from typing import Dict, List, Optional
from .config import settings
from .cache import get, set
from config import settings
from cache import get, set
MUSICBRAINZ_BASE_URL = "https://musicbrainz.org/ws/2"
+2 -2
View File
@@ -3,8 +3,8 @@
import requests
from typing import Dict, List, Optional
from .config import settings
from .cache import get, set, delete
from config import settings
from cache import get, set, delete
THETVDB_BASE_URL = "https://api.thetvdb.com"
+2 -2
View File
@@ -3,8 +3,8 @@
import requests
from typing import Dict, List, Optional
from .config import settings
from .cache import get, set, delete
from config import settings
from cache import get, set, delete
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 urllib.parse import urlparse
from .clients.tmdb import TMDBClient
from .clients.thetvdb import TheTVDBClient
from .config import settings
from clients.tmdb import TMDBClient
from clients.thetvdb import TheTVDBClient
from config import settings
class ImageDownloader:
+7 -7
View File
@@ -13,13 +13,13 @@ import time
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .cache import init_cache, set
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 .prescan import PreScan
from .nfo_generator import NFOGenerator
from .image_downloader import ImageDownloader
from config import settings
from cache import init_cache, set
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 prescan import PreScan
from nfo_generator import NFOGenerator
from image_downloader import ImageDownloader
app = FastAPI(
title="Rippy API",
+5 -5
View File
@@ -4,11 +4,11 @@ import subprocess
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from .clients.tmdb import TMDBClient
from .clients.musicbrainz import MusicBrainzClient
from .clients.thetvdb import TheTVDBClient
from .cache import get, set
from .config import settings
from clients.tmdb import TMDBClient
from clients.musicbrainz import MusicBrainzClient
from clients.thetvdb import TheTVDBClient
from cache import get, set
from config import settings
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 typing import Dict, Optional
from .config import settings
from config import settings
# Default Rate Limit
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 { LayoutDashboard, Disc, Settings, LogOut } from 'lucide-react'
import Dashboard from './pages/Dashboard'
import MetadataPreview from './pages/MetadataPreview'
import SettingsPage from './pages/Settings'
type Page = 'dashboard' | 'metadata' | 'settings'
function App() {
const [page, setPage] = useState<'dashboard' | 'metadata'>('dashboard')
const [page, setPage] = useState<Page>('dashboard')
const navItems = [
{ id: 'dashboard', label: 'Dashboard' },
{ id: 'metadata', label: 'Metadaten-Preview' },
{ id: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
{ id: 'metadata', label: 'Metadaten', icon: Disc },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
]
return (
<div className="min-h-screen bg-gray-100">
{/* Navigation */}
<nav className="bg-white shadow-md">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between h-16">
<div className="flex items-center">
<div className="flex-shrink-0 flex items-center gap-2">
<svg className="h-8 w-8 text-blue-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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" />
</svg>
<span className="font-bold text-xl text-gray-900">Rippy</span>
<div className="min-h-screen bg-slate-50 text-slate-900 font-sans">
<div className="flex h-screen overflow-hidden">
{/* Sidebar */}
<aside className="w-64 bg-slate-900 text-white flex flex-col">
<div className="p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-gradient-to-br from-indigo-500 to-purple-600 rounded-xl">
<Disc size={28} className="text-white" />
</div>
<div>
<h1 className="text-xl font-bold tracking-tight">Rippy</h1>
<p className="text-xs text-slate-400">Automated Ripping</p>
</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>
</nav>
{/* Content */}
<main className="max-w-7xl mx-auto py-6">
{page === 'dashboard' ? <Dashboard /> : <MetadataPreview />}
</main>
<nav className="flex-1 px-3 py-2 space-y-1">
{navItems.map((item) => (
<button
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>
)
}
+184 -83
View File
@@ -1,7 +1,6 @@
import { useEffect, useState } from 'react'
import axios from 'axios'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { Clock, Activity, HardDrive, BarChart, AlertCircle, CheckCircle, Disc, Play, Pause, SkipForward } from 'lucide-react'
interface Job {
id: string
@@ -11,6 +10,7 @@ interface Job {
startTime: string
endTime?: string
progress: number
title?: string
}
interface Device {
@@ -43,33 +43,56 @@ async function fetchDevices(): Promise<Device[]> {
function StatusBadge({ status }: { status: string }) {
const styles = {
pending: 'bg-yellow-100 text-yellow-800',
processing: 'bg-blue-100 text-blue-800',
completed: 'bg-green-100 text-green-800',
failed: 'bg-red-100 text-red-800',
pending: 'bg-amber-100 text-amber-700 ring-amber-600/20',
processing: 'bg-blue-100 text-blue-700 ring-blue-600/20',
completed: 'bg-emerald-100 text-emerald-700 ring-emerald-600/20',
failed: 'bg-rose-100 text-rose-700 ring-rose-600/20',
}
const labels = {
pending: 'In Warteschlange',
processing: 'In Bearbeitung',
completed: 'Abgeschlossen',
failed: 'Fehlgeschlagen',
pending: 'Wartend',
processing: 'Bearbeitung',
completed: 'Fertig',
failed: 'Fehler',
}
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}
</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 }) {
return (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
<div className="w-32">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-medium text-slate-500">Fortschritt</span>
<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>
)
}
@@ -107,112 +130,181 @@ export default function Dashboard() {
if (loading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
<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="p-6 max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900">Dashboard</h1>
<p className="text-gray-600 mt-1">Übersicht über alle Ripping-Jobs und Geräte</p>
<div className="space-y-8">
{/* Header */}
<div className="flex flex-col gap-2">
<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>
{/* 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
icon={Clock}
label="Warteschlange"
value={stats.pending.toString()}
color="yellow"
color="amber"
/>
<StatCard
icon={Activity}
label="In Bearbeitung"
label="Bearbeitung"
value={stats.processing.toString()}
color="blue"
/>
<StatCard
icon={CheckCircle}
label="Abgeschlossen"
label="Fertig"
value={stats.completed.toString()}
color="green"
color="emerald"
/>
<StatCard
icon={AlertCircle}
label="Fehlgeschlagen"
label="Fehler"
value={stats.failed.toString()}
color="red"
color="rose"
/>
</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 */}
<div className="mb-8">
<h2 className="text-xl font-semibold text-gray-900 mb-4">Geräte</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200">
<h2 className="text-lg font-semibold text-slate-900">Geräte</h2>
</div>
<div className="p-6">
{devices.length === 0 ? (
<div className="text-gray-500">Keine Geräte gefunden</div>
) : (
devices.map(device => (
<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 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">
<HardDrive className="text-slate-400" size={32} />
</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>
{/* Recent Jobs */}
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-4">Neueste Jobs</h2>
<div className="bg-white rounded-lg shadow overflow-hidden">
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<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">
<thead className="bg-gray-50">
<thead className="bg-slate-50">
<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-medium text-gray-500 uppercase">Status</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-medium text-gray-500 uppercase">Fortschritt</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">Typ</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-semibold text-slate-500 uppercase tracking-wider">Status</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-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>
</thead>
<tbody className="divide-y divide-gray-200">
<tbody className="divide-y divide-slate-200">
{jobs.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-4 text-center text-gray-500">
Keine Jobs vorhanden
<td colSpan={6} className="px-6 py-12 text-center">
<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>
</tr>
) : (
jobs.slice(0, 10).map(job => (
<tr key={job.id} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium capitalize">{job.type}</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-gray-500">{job.device}</td>
<tr key={job.id} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 whitespace-nowrap">
<TypeBadge type={job.type} />
</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">
<ProgressBar progress={job.progress} />
</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')}
</td>
</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 = {
yellow: 'bg-yellow-50 text-yellow-600',
blue: 'bg-blue-50 text-blue-600',
green: 'bg-green-50 text-green-600',
red: 'bg-red-50 text-red-600',
amber: 'bg-amber-500',
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
rose: 'bg-rose-500',
}
const textColors = {
amber: 'text-amber-600',
blue: 'text-blue-600',
emerald: 'text-emerald-600',
rose: 'text-rose-600',
}
return (
<div className={`p-4 rounded-lg ${colors[color as keyof typeof colors]}`}>
<div className="flex items-center gap-3">
<Icon size={24} />
<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-4">
<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>
<p className="text-sm font-medium opacity-90">{label}</p>
<p className="text-2xl font-bold">{value}</p>
<p className="text-sm font-medium text-slate-500">{label}</p>
<p className="text-3xl font-bold text-slate-900">{value}</p>
</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>
)
}