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

This commit is contained in:
Hitonabi
2026-07-24 16:04:46 +02:00
parent 7004d798d6
commit 90b708302e
10 changed files with 808 additions and 869 deletions
+210 -192
View File
@@ -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 (
<div className="space-y-6">
{/* Vorhandene Ziele */}
<div>
<div className="flex items-center justify-between mb-3">
<h3 className={`font-medium ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>Verfügbare Ziele</h3>
<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'}`}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<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} />
</button>
</div>
{targets.length === 0 ? (
<p className={`text-sm ${theme === 'dark' ? 'text-slate-500' : 'text-slate-400'}`}>Noch keine Unterordner in /app/media sie entstehen beim ersten Rip oder Mount.</p>
) : (
<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>
</Button>
</CardHeader>
<CardContent>
{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>
) : (
<>
{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 text-left transition-colors ${theme === 'dark' ? 'text-slate-300 hover:bg-slate-700' : 'text-slate-700 hover:bg-slate-100'}`}
>
<Folder size={15} className={theme === 'dark' ? 'text-slate-500' : 'text-slate-400'} />
{d.name}
</button>
))}
{/* Dateien mit anzeigen (grau, nicht klickbar) — vorher wirkte
z. B. der bluray-Ordner „leer", obwohl die MKVs drinlagen */}
{browseFiles.map(f => (
<div
key={f.name}
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 className="space-y-2.5">
{targets.map(t => (
<div key={t.path} className="flex items-center justify-between p-3.5 rounded-xl border border-slate-200/80 dark:border-slate-800 bg-slate-50 dark:bg-slate-950/80">
<div className="flex items-center gap-3">
<HardDrive size={18} className={t.is_mount ? 'text-amber-500' : 'text-slate-400 dark:text-slate-500'} />
<div>
<span className="text-sm font-medium text-slate-900 dark:text-slate-200">{t.name}</span>
<span className="text-xs ml-2 text-slate-500 dark:text-slate-400">
{t.is_mount ? 'Netzwerk-Mount' : 'lokal'}{t.free_gb != null ? ` · ${t.free_gb} GB frei` : ''}
</span>
</div>
</div>
{mounts.some(m => m.name === t.name) && (
<button onClick={() => entfernen(t.name)} disabled={busy} className="p-1.5 rounded-lg text-rose-500 hover:bg-rose-500/10 transition-colors">
<Trash2 size={16} />
</button>
)}
</div>
))}
</>
</div>
)}
</div>
</>)}
</div>
</CardContent>
</Card>
{/* 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 */}
<div className={`rounded-lg p-4 border ${theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50'}`}>
<h3 className={`font-medium mb-3 flex items-center gap-2 ${theme === 'dark' ? 'text-slate-100' : 'text-slate-900'}`}>
<Plus size={18} className="text-indigo-500" /> Netzwerk-Ziel hinzufügen
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Name (klein, ohne Leerzeichen)</label>
<input value={name} onChange={e => setName(e.target.value.toLowerCase())} className={feld} placeholder="nas-filme" />
</div>
<div>
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Typ</label>
<select value={typ} onChange={e => setTyp(e.target.value as 'nfs' | 'cifs')} className={feld}>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Plus size={20} className="text-amber-500" />
<span>Netzwerk-Ziel hinzufügen</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Name (klein, ohne Leerzeichen)"
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="cifs">SMB/CIFS (Windows-Freigabe, NAS)</option>
</select>
</div>
{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>
) : (
<>
</Select>
{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'}`}>
Rechner (dein PC oder NAS Name oder IP)
</label>
<input value={host} onChange={e => setHost(e.target.value)} className={feld} placeholder="192.168.178.20 oder TOBIS-PC" />
<Input
label="Quelle (host:/export/pfad)"
value={source}
onChange={e => setSource(e.target.value)}
placeholder="192.168.1.10:/volume1/filme"
/>
</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">
<label className={`block text-xs font-medium mb-1 ${theme === 'dark' ? 'text-slate-400' : 'text-slate-500'}`}>Freigabe wählen</label>
<select value={selectedShare} onChange={e => setSelectedShare(e.target.value)} className={feld}>
{shares.map(s => <option key={s} value={s}>{s}</option>)}
</select>
<Input
label="Rechner (dein PC oder NAS — Name oder IP)"
value={host}
onChange={e => setHost(e.target.value)}
placeholder="192.168.178.20 oder TOBIS-PC"
/>
</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>
<button
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>
</CardContent>
</Card>
</div>
)
}