Ampel / ampel (push) Failing after 54s
der Ersteinrichtungs-Assistent
Der Befund des Commanders: "Ausserdem fehlt der 1st run wizzard wenn man es
installiert." Meine erste Reparatur (App.tsx las einen gescheiterten Abruf
als "erledigt") war richtig, aber nicht die Ursache. Nachgemessen:
Installation %LOCALAPPDATA%\Rippy wird deinstalliert
Datenbank %ProgramData%\Rippy BLEIBT LIEGEN
Eine "frische" Installation erbte damit die alte Datenbank samt
`setup.done = true` -- der Assistent erschien nie wieder. Gegengeprueft:
nach dem Aufraeumen meldet /api/setup wieder {"done": false}, und das
Fenster zeigt "Willkommen bei Rippy".
Der Grund fuer den alten Ort stand im Docstring: "Ein Programmordner ist
unter Windows fuer einen DIENST nicht zuverlaessig beschreibbar." Das stimmte
-- fuer den Windows-Dienst, den es nie gab. Rippy laeuft als der angemeldete
Benutzer (Autostart unter HKCU). Jetzt liegt alles, was Rippy gehoert, in
EINEM Ordner: Programm, UI, Werkzeuge, Protokoll, WebView2-Zwischenspeicher
und die Datenbank.
Dazu:
* `datenbank_umziehen()` holt eine vorhandene Datenbank vom alten Ort ab --
wer Rippy schon benutzt hat, behaelt Jobs und Einstellungen. Gibt es am
neuen Ort schon eine, bleibt sie unangetastet: Die BENUTZTE gewinnt.
* `deinstallieren()` raeumt den alten Ort mit weg, und `alte_orte()` fasst
nur einen Ordner an, der wirklich "Rippy" heisst.
* Der Ersteinrichtungs-Assistent spricht nicht mehr von "Encoding-Worker",
wenn es keine gibt -- dort steht jetzt "Kompression". Gegengeprueft: kein
"docker", kein "Container", kein "Worker" mehr im Text.
Ampel lokal: 709 gruen, ruff sauber.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
487 lines
22 KiB
TypeScript
487 lines
22 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle, KeyRound } from 'lucide-react'
|
||
import { api } from '../lib/api'
|
||
import { useBetrieb } from '../lib/useBetrieb'
|
||
import { MEDIA_SERVER_OPTIONEN } from '../lib/mediaServer'
|
||
import { PRESET_KEINE, schwacheEncoderCpu } from '../lib/encoder'
|
||
import { Button } from './ui/Button'
|
||
import { Input, Select } from './ui/Input'
|
||
|
||
interface WorkerInfo {
|
||
name: string
|
||
encoders: string[]
|
||
online?: boolean
|
||
info?: { cpu_kerne?: string; cpu_simd?: string; cpu_modell?: string }
|
||
}
|
||
|
||
interface GeraetInfo {
|
||
id: string
|
||
name: string
|
||
status: string
|
||
}
|
||
|
||
interface PlatzInfo {
|
||
name: string
|
||
frei_gb: number
|
||
gesamt_gb: number
|
||
}
|
||
|
||
const ENCODER_LABELS: Record<string, string> = {
|
||
'cpu-x264': 'CPU · H.264 (x264)',
|
||
'cpu-x265': 'CPU · H.265 (x265)',
|
||
'cpu-av1': 'CPU · AV1 (SVT-AV1)',
|
||
'vaapi': 'Hardware · VAAPI (AMD/Intel)',
|
||
'nvenc': 'Hardware · NVENC (NVIDIA)',
|
||
}
|
||
|
||
// Unter diesem Wert lohnt der Hinweis auf die Ablage: eine einzelne Blu-ray
|
||
// braucht roh rund 40 GB, eine 4K-UHD bis 100 GB.
|
||
const PLATZ_WARNUNG_GB = 60
|
||
|
||
// Antwort von GET /presets — dieselbe Quelle, die auch die Einstellungen-Seite
|
||
// benutzt. Bis 26.07.2026 entschied der Wizard hier selbst und mit fest
|
||
// verdrahteten Namen; jetzt kommt beides aus api/presets.py, wo es Tests hat.
|
||
interface PresetUebersicht {
|
||
quelle: 'worker' | 'rueckfall'
|
||
hardware: string[]
|
||
typen: Record<'uhd' | 'bluray' | 'dvd', {
|
||
empfehlung: { preset: string, grund: string, gefunden: boolean }
|
||
}>
|
||
}
|
||
|
||
/** Was beim Abschliessen gespeichert wird — die Empfehlung der API. */
|
||
function presetsFuer(uebersicht: PresetUebersicht | null) {
|
||
const nimm = (typ: 'uhd' | 'bluray' | 'dvd') => {
|
||
const e = uebersicht?.typen?.[typ]?.empfehlung
|
||
return e?.gefunden ? e.preset : ''
|
||
}
|
||
const uhd = nimm('uhd')
|
||
const bluray = nimm('bluray')
|
||
const dvd = nimm('dvd')
|
||
// Leere Werte gar nicht schreiben: dann bleibt der Bestandswert stehen,
|
||
// statt dass HandBrake später ein leeres --preset bekommt.
|
||
return {
|
||
...(bluray ? { transcodePreset: bluray, transcodePresetBluray: bluray } : {}),
|
||
...(dvd ? { transcodePresetDvd: dvd } : {}),
|
||
...(uhd ? { transcodePresetUhd: uhd } : {}),
|
||
}
|
||
}
|
||
|
||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||
const [tmdbKey, setTmdbKey] = useState('')
|
||
const [omdbKey, setOmdbKey] = useState('')
|
||
// MakeMKV-Beta-Key: Lizenz fuer die SOFTWARE (Blu-ray), nicht der
|
||
// Disc-Schluessel einer einzelnen 4K-Disc.
|
||
const [makemkvKey, setMakemkvKey] = useState('')
|
||
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||
const betrieb = useBetrieb()
|
||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||
const [presetInfo, setPresetInfo] = useState<PresetUebersicht | null>(null)
|
||
const [geladen, setGeladen] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [keyWarnung, setKeyWarnung] = useState<string | null>(null)
|
||
|
||
// Wiederholt laden, bis Worker UND Laufwerk da sind: beim allerersten Start
|
||
// läuft der Worker-Container oft noch hoch. Ohne das stand hier dauerhaft
|
||
// „Noch kein Worker gemeldet" und der Nutzer wusste nicht, ob er warten soll.
|
||
useEffect(() => {
|
||
let laeuft = true
|
||
const laden = async () => {
|
||
const [caps, devs, info, pres] = await Promise.allSettled([
|
||
api.get('/capabilities'),
|
||
api.get('/devices'),
|
||
api.get('/system/info'),
|
||
api.get('/presets'),
|
||
])
|
||
if (!laeuft) return
|
||
if (caps.status === 'fulfilled') setWorkers(caps.value.data?.workers || [])
|
||
if (devs.status === 'fulfilled') setGeraete(devs.value.data || [])
|
||
if (info.status === 'fulfilled') setPlaetze(info.value.data?.plaetze || [])
|
||
if (pres.status === 'fulfilled') setPresetInfo(pres.value.data || null)
|
||
setGeladen(true)
|
||
}
|
||
laden()
|
||
const takt = setInterval(laden, 5000)
|
||
return () => { laeuft = false; clearInterval(takt) }
|
||
}, [])
|
||
|
||
const schwach = schwacheEncoderCpu(workers)
|
||
const presets = presetsFuer(presetInfo)
|
||
const encoderWorker = workers.filter(w => w.encoders.length > 0)
|
||
const platz = plaetze.length > 0 ? Math.min(...plaetze.map(p => p.frei_gb)) : null
|
||
|
||
const abschliessen = async () => {
|
||
setSaving(true)
|
||
setError(null)
|
||
setKeyWarnung(null)
|
||
try {
|
||
const bestehende = (await api.get('/settings')).data || {}
|
||
await api.post('/settings', {
|
||
...bestehende,
|
||
...(tmdbKey.trim() ? { tmdbApiKey: tmdbKey.trim() } : {}),
|
||
...(omdbKey.trim() ? { omdbApiKey: omdbKey.trim() } : {}),
|
||
...(makemkvKey.trim() ? { makemkvAppKey: makemkvKey.trim() } : {}),
|
||
mediaServer,
|
||
transcodeEnabled,
|
||
...presets,
|
||
})
|
||
// Erst SPEICHERN, dann prüfen: /metadata/status liest die Keys aus der
|
||
// Datenbank, nicht aus dem Formular. Ein falsch kopierter Key fällt so
|
||
// sofort auf, statt erst beim ersten Rip als „Unknown Disc".
|
||
if (tmdbKey.trim() || omdbKey.trim()) {
|
||
try {
|
||
const s = (await api.get('/metadata/status')).data || {}
|
||
const kaputt = Object.entries(s)
|
||
.filter(([, v]) => v === 'fehler')
|
||
.map(([k]) => k.toUpperCase())
|
||
if (kaputt.length > 0) {
|
||
setKeyWarnung(
|
||
`${kaputt.join(' und ')} hat den Key nicht angenommen — vermutlich ein `
|
||
+ 'Tippfehler beim Kopieren. Rippy laeuft trotzdem, die Titel heissen dann '
|
||
+ 'nur wie das Disc-Label. Nachbessern: Einstellungen → APIs.'
|
||
)
|
||
setSaving(false)
|
||
return // nicht wegklicken, damit die Warnung gelesen wird
|
||
}
|
||
} catch {
|
||
// Prüfung selbst kaputt → kein Grund, die Einrichtung zu blockieren
|
||
}
|
||
}
|
||
await api.post('/setup/complete')
|
||
onDone()
|
||
} catch {
|
||
setError('Speichern fehlgeschlagen — läuft die API? (Seite neu laden hilft meist.)')
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
const trotzdemWeiter = async () => {
|
||
setSaving(true)
|
||
try {
|
||
await api.post('/setup/complete')
|
||
onDone()
|
||
} catch {
|
||
setError('Speichern fehlgeschlagen — läuft die API?')
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen flex items-center justify-center p-6 bg-slate-100 dark:bg-slate-950">
|
||
<div className="w-full max-w-2xl rounded-2xl shadow-2xl overflow-hidden bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800">
|
||
<div className="bg-gradient-to-r from-amber-500 via-indigo-600 to-purple-700 p-8 text-white">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<Disc size={32} className="animate-disc-spin" />
|
||
<h1 className="text-2xl font-bold">Willkommen bei Rippy</h1>
|
||
</div>
|
||
<p className="opacity-90">
|
||
Einmalige Einrichtung — dauert keine zwei Minuten. Du kannst nichts kaputt
|
||
machen: alles ist später unter Einstellungen änderbar.
|
||
</p>
|
||
</div>
|
||
|
||
<div className="p-8 space-y-8">
|
||
{/* ---- Was Rippy gerade sieht ---------------------------------- */}
|
||
<section>
|
||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||
<HardDrive size={18} className="text-amber-500" /> Was Rippy gerade sieht
|
||
</h2>
|
||
<div className="rounded-xl border border-slate-200 dark:border-slate-800 divide-y divide-slate-200 dark:divide-slate-800 overflow-hidden">
|
||
<Zeile
|
||
titel="Laufwerk"
|
||
geladen={geladen}
|
||
gut={geraete.length > 0}
|
||
gutText={geraete.map(g => g.name).join(', ')}
|
||
schlechtText="Kein optisches Laufwerk gefunden. Rippy kann dann nur komprimieren, nicht rippen. Prüfe, ob das Laufwerk angeschlossen und (bei einer VM) durchgereicht ist."
|
||
/>
|
||
<Zeile
|
||
/* Auf einem Windows-PC gibt es keinen "Encoding-Worker" und
|
||
keinen Worker-Container -- Rippy komprimiert selbst. Der
|
||
Commander am 28.08.2026: "Das gilt fuer die ganze
|
||
standalone version fuer Windows." */
|
||
titel={betrieb.kann.externe_worker ? 'Encoding-Worker' : 'Kompression'}
|
||
geladen={geladen}
|
||
gut={encoderWorker.length > 0}
|
||
gutText={encoderWorker.map(w => {
|
||
const kerne = w.info?.cpu_kerne
|
||
const simd = w.info?.cpu_simd
|
||
const zusatz = [kerne ? `${kerne} Kerne` : '', simd && simd !== 'unbekannt' ? simd : '']
|
||
.filter(Boolean).join(', ')
|
||
return zusatz ? `${w.name} (${zusatz})` : w.name
|
||
}).join(' · ')}
|
||
schlechtText={betrieb.kann.externe_worker
|
||
? 'Noch kein Worker gemeldet. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst. Bleibt es dabei, läuft der Worker-Container nicht.'
|
||
: 'Rippy meldet noch keine Encoder. Beim ersten Start dauert das bis zu einer Minute — diese Anzeige aktualisiert sich selbst.'}
|
||
/>
|
||
<Zeile
|
||
titel="Freier Platz"
|
||
geladen={geladen}
|
||
gut={platz === null || platz >= PLATZ_WARNUNG_GB}
|
||
gutText={platz === null ? '—' : `${platz.toFixed(0)} GB`}
|
||
schlechtText={`Nur ${platz?.toFixed(0)} GB frei. Eine Blu-ray braucht roh rund 40 GB, eine 4K-UHD bis 100 GB. Lege die Ablage besser auf eine NAS-Freigabe (Einstellungen → Speicherziele).`}
|
||
/>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---- Metadaten ---------------------------------------------- */}
|
||
<section>
|
||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||
<Globe size={18} className="text-amber-500" /> Metadaten-Erkennung
|
||
</h2>
|
||
<div className="space-y-3">
|
||
{/* Bewusst SICHTBAR statt type=password: Das sind kopierte Keys,
|
||
keine Passwoerter — und einen Tippfehler sieht man in Punkten
|
||
nicht. Geprueft wird direkt nach dem Speichern. */}
|
||
<Input
|
||
label="TMDB API-Key (empfohlen)"
|
||
value={tmdbKey}
|
||
onChange={e => setTmdbKey(e.target.value)}
|
||
placeholder="Kostenlos auf themoviedb.org — hier einfügen"
|
||
/>
|
||
<Input
|
||
label="OMDb API-Key (optional, zweite Quelle)"
|
||
value={omdbKey}
|
||
onChange={e => setOmdbKey(e.target.value)}
|
||
placeholder="Kostenlos auf omdbapi.com"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Ohne Keys rippt Rippy trotzdem — Discs heißen dann nur wie ihr Volume-Label.
|
||
Beide Keys werden direkt nach dem Speichern geprüft.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---- MakeMKV-Beta-Key --------------------------------------- */}
|
||
{/*
|
||
Neu 26.07.2026, bei der Weitergabe-Prüfung aufgefallen: Der Key war
|
||
in der README, in der .env und in den Einstellungen zu finden — nur
|
||
NICHT hier. Ein Fremder installiert also, legt eine Blu-ray ein und
|
||
bekommt irgendwann einen Fehlschlag, ohne dass ihn beim Einrichten
|
||
jemand darauf hingewiesen hätte. Das ist die wahrscheinlichste
|
||
Stolperstelle einer frischen Installation.
|
||
*/}
|
||
<section>
|
||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||
<KeyRound size={18} className="text-amber-500" /> MakeMKV-Beta-Key
|
||
</h2>
|
||
<div className="space-y-3">
|
||
<Input
|
||
label="Beta-Key (für Blu-ray; DVDs gehen ohne)"
|
||
value={makemkvKey}
|
||
onChange={e => setMakemkvKey(e.target.value)}
|
||
placeholder="T-… — kostenlos im MakeMKV-Forum, Thread t=1053"
|
||
/>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
MakeMKV ist für <strong>DVDs dauerhaft kostenlos</strong>. Für
|
||
<strong> Blu-ray und 4K</strong> braucht es den Beta-Key; ohne ihn läuft
|
||
eine 30-Tage-Probezeit, danach bricht jeder Blu-ray-Rip ab. Der Key ist
|
||
kostenlos, wechselt aber etwa monatlich — nachpflegen unter
|
||
Einstellungen → System, das wirkt sofort ab dem nächsten Rip, ohne
|
||
Neubau.
|
||
</p>
|
||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||
Nicht verwechseln mit dem <strong>Disc-Schlüssel</strong> einer einzelnen
|
||
4K-Disc — das ist eine andere Sache und steht unter Einstellungen → System.
|
||
</p>
|
||
</div>
|
||
</section>
|
||
|
||
{/* ---- Media-Server ------------------------------------------- */}
|
||
<section>
|
||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||
<Tv size={18} className="text-amber-500" /> Dein Media-Server
|
||
</h2>
|
||
<p className="text-sm mb-3 text-slate-500 dark:text-slate-400">
|
||
Rippy benennt fertige Rips als „Titel (Jahr)" und legt für Jellyfin/Emby/Kodi
|
||
zusätzlich movie.nfo + poster.jpg dazu.
|
||
</p>
|
||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||
{MEDIA_SERVER_OPTIONEN.map(o => (
|
||
<button
|
||
key={o.wert}
|
||
type="button"
|
||
onClick={() => setMediaServer(o.wert)}
|
||
title={o.hinweis}
|
||
className={`px-3 py-2 rounded-xl text-sm font-medium border transition-all ${
|
||
mediaServer === o.wert
|
||
? 'bg-amber-500/20 border-amber-500 text-amber-600 dark:text-amber-300 font-semibold'
|
||
: 'bg-slate-50 dark:bg-slate-800 border-slate-200 dark:border-slate-700 text-slate-600 dark:text-slate-400 hover:border-slate-300 dark:hover:border-slate-600'
|
||
}`}
|
||
>
|
||
{o.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="text-xs mt-2 text-slate-500 dark:text-slate-400">
|
||
{MEDIA_SERVER_OPTIONEN.find(o => o.wert === mediaServer)?.hinweis}
|
||
</p>
|
||
</section>
|
||
|
||
{/* ---- Verarbeitung ------------------------------------------- */}
|
||
<section>
|
||
<h2 className="flex items-center gap-2 text-lg font-semibold mb-3 text-slate-900 dark:text-slate-100">
|
||
<Cpu size={18} className="text-amber-500" /> Verarbeitung
|
||
</h2>
|
||
|
||
<label className="flex items-start gap-3 mb-3 text-sm text-slate-700 dark:text-slate-300">
|
||
<input
|
||
type="checkbox"
|
||
checked={transcodeEnabled}
|
||
onChange={e => setTranscodeEnabled(e.target.checked)}
|
||
className="w-4 h-4 mt-0.5 accent-amber-500 rounded"
|
||
/>
|
||
<span>
|
||
Nach dem Rip auf Arbeitsgröße komprimieren
|
||
<span className="block text-xs text-slate-500 dark:text-slate-400">
|
||
Empfohlen — ohne Kompression braucht jede Blu-ray rund 40 GB.
|
||
</span>
|
||
</span>
|
||
</label>
|
||
|
||
{transcodeEnabled && (
|
||
<div className={`rounded-xl p-4 border ${
|
||
schwach
|
||
? 'bg-amber-500/10 border-amber-500/30'
|
||
: 'bg-slate-50 dark:bg-slate-950/80 border-slate-200 dark:border-slate-800'
|
||
}`}>
|
||
{/* Der Kern dieser Seite: Die Empfehlung folgt der GEMESSENEN
|
||
Rechenleistung. Vorher stand hier H.265 als Standard - auf
|
||
einer CPU ohne AVX2 sind das 28-55 Stunden je 4K-Film, und
|
||
genau das ist am 25.07.2026 passiert. */}
|
||
{schwach ? (
|
||
<>
|
||
<p className="text-sm font-medium flex items-start gap-2 text-amber-700 dark:text-amber-300">
|
||
<AlertTriangle size={16} className="mt-0.5 flex-shrink-0" />
|
||
<span>Rippy hat deine Rechenleistung gemessen und passt die Wahl an.</span>
|
||
</p>
|
||
<p className="text-xs mt-2 text-amber-700/90 dark:text-amber-300/90">
|
||
{betrieb.kann.externe_worker ? 'Keiner deiner Worker' : 'Dieser Rechner'} kann kein AVX2 — die Vektorbefehle, von denen H.265
|
||
lebt. 4K in H.265 würde hier <strong>ein bis zwei Tage pro Film</strong>
|
||
{' '}dauern. Deshalb wird eingestellt:
|
||
</p>
|
||
</>
|
||
) : presetInfo && presetInfo.hardware.length > 0 ? (
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||
Hardware-Encoder gemeldet ({presetInfo.hardware.join(', ')}) — Rippy nimmt ihn:
|
||
</p>
|
||
) : (
|
||
<p className="text-sm font-medium text-slate-700 dark:text-slate-300">
|
||
Deine Worker sind schnell genug für H.265 — Rippy stellt ein:
|
||
</p>
|
||
)}
|
||
<ul className="mt-2 space-y-1 text-xs text-slate-600 dark:text-slate-400">
|
||
<li>
|
||
<strong>4K-UHD:</strong>{' '}
|
||
{presets.transcodePresetUhd === PRESET_KEINE
|
||
? 'nicht komprimieren, verlustfrei behalten (20–100 GB je Film)'
|
||
: presets.transcodePresetUhd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||
</li>
|
||
<li>
|
||
<strong>Blu-ray:</strong>{' '}
|
||
{presets.transcodePresetBluray || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||
</li>
|
||
<li>
|
||
<strong>DVD:</strong>{' '}
|
||
{presets.transcodePresetDvd || 'unverändert (kein bekanntes Preset gemeldet)'}
|
||
</li>
|
||
</ul>
|
||
{presetInfo?.typen?.uhd?.empfehlung?.grund && (
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||
Warum: {presetInfo.typen.uhd.empfehlung.grund}
|
||
</p>
|
||
)}
|
||
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
|
||
Alles einzeln änderbar unter Einstellungen → Verarbeitung.
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{encoderWorker.length > 0 && (
|
||
<div className="mt-3 flex flex-wrap gap-1">
|
||
{encoderWorker.flatMap(w => w.encoders).filter((e, i, a) => a.indexOf(e) === i).map(e => (
|
||
<span key={e} className={`text-xs px-2 py-0.5 rounded font-medium ${
|
||
e.startsWith('cpu')
|
||
? 'bg-slate-200 dark:bg-slate-800 text-slate-600 dark:text-slate-300'
|
||
: 'bg-emerald-500/15 text-emerald-600 dark:text-emerald-400'
|
||
}`}>
|
||
{ENCODER_LABELS[e] || e}
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
{keyWarnung && (
|
||
<div className="p-4 rounded-xl bg-amber-500/10 border border-amber-500/30">
|
||
<p className="text-sm text-amber-700 dark:text-amber-300">{keyWarnung}</p>
|
||
<div className="flex gap-2 mt-3">
|
||
<Button variant="secondary" size="sm" onClick={() => setKeyWarnung(null)}>
|
||
Key korrigieren
|
||
</Button>
|
||
<Button variant="secondary" size="sm" onClick={trotzdemWeiter} disabled={saving}>
|
||
Trotzdem fertigstellen
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{error && (
|
||
<p className="text-sm p-3 rounded-lg bg-rose-500/10 border border-rose-500/30 text-rose-600 dark:text-rose-400">
|
||
{error}
|
||
</p>
|
||
)}
|
||
|
||
<Button
|
||
variant="amber"
|
||
size="lg"
|
||
onClick={abschliessen}
|
||
disabled={saving}
|
||
className="w-full"
|
||
>
|
||
{saving ? 'Speichern…' : <>Los geht's <ArrowRight size={18} /></>}
|
||
</Button>
|
||
|
||
<p className="text-xs text-center flex items-center justify-center gap-1 text-slate-500 dark:text-slate-400">
|
||
<CheckCircle size={12} /> Läuft komplett lokal — keine Cloud, keine Telemetrie.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
/** Eine Zeile im „Was Rippy gerade sieht"-Kasten: gruen, wenn es passt, sonst
|
||
* bernstein MIT Handlungsanweisung. Nie nur ein Kreuz ohne Erklaerung. */
|
||
function Zeile({ titel, geladen, gut, gutText, schlechtText }: {
|
||
titel: string
|
||
geladen: boolean
|
||
gut: boolean
|
||
gutText: string
|
||
schlechtText: string
|
||
}) {
|
||
if (!geladen) {
|
||
return (
|
||
<div className="flex items-center gap-3 p-3">
|
||
<span className="w-2 h-2 rounded-full bg-slate-300 dark:bg-slate-600 animate-pulse flex-shrink-0" />
|
||
<span className="text-sm text-slate-500 dark:text-slate-400">{titel} — wird geprüft…</span>
|
||
</div>
|
||
)
|
||
}
|
||
return (
|
||
<div className="flex items-start gap-3 p-3">
|
||
<span className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${gut ? 'bg-emerald-500' : 'bg-amber-500'}`} />
|
||
<div className="min-w-0">
|
||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||
<span className="font-medium">{titel}:</span>{' '}
|
||
{gut ? gutText : <span className="text-amber-600 dark:text-amber-400">nicht bereit</span>}
|
||
</p>
|
||
{!gut && <p className="text-xs mt-0.5 text-amber-700/90 dark:text-amber-300/90">{schlechtText}</p>}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|