feat(v5): 5.5.0 — Kern und Oberfläche aufgeteilt, Übersicht und Einstellungen neu, Erst-Einrichtung mit Poster-Kulisse

Commander-Wünsche vom 02.09.2026 vor der Freigabe von 5.4.0 (die deshalb nie
veröffentlicht wurde):

Kern: kern/index.ts ist nur noch Bootstrap und Router; Dienste in
kern/dienste/ (Einstellungen, Werkzeuge, Laufwerke, Erkennung, Vorgänge,
Speicher, Benachrichtigung, Automatik), Kontext in kern/kontext.ts,
Protokoll-Datei in gemeinsam/protokoll.ts, Platz-Rechnung in gemeinsam/platz.ts.

Oberfläche: fenster/App.tsx ist nur noch der Rahmen; bausteine.tsx, dialoge/,
uebersicht/ (LaufwerkKachel, KompressionsKarte, SpeicherKarte, VerlaufKarte),
einstellungen/ (Karten je Funktion, SprachAuswahl), einrichtung/
(ErstEinrichtung, PosterHintergrund).

Neu: Bibliothek weg, dafür Verlauf; Speicher-und-Bilanz-Karte (statfs);
LED-Fußleiste; MakeMKV-Kopf in die Einstellungen; Sprach-Chips mit Dropdown
statt Allgemeinem Preset; Karten Automatik/Benachrichtigung/Allgemein/Update;
Durchsuchen bei Werkzeugen; Arbeitsordner lokal (Encode dort, dann in die
Ablage); Platz-Prüfung vor dem Rip (Dialog und Kern); Automatik mit Countdown;
Serien-Merker über Discs; Rettungs-Abbild mit Nullen nur nach gesehenen
Lesefehlern (kern/rip/rettung.ts, Phase rettet, iso:-Quelle); Warteschlange
pausieren; Discord/Telegram-Benachrichtigung; Protokoll-Datei je Tag;
Fensterlage 80 % mittig, gemerkt; Erst-Einrichtung in acht Schritten vor dem
Poster-Grid.

Beweise: 413 Tests (neu: speicher, benachrichtigung, automatik, rettung,
fensterlage, protokoll, kleinkram-550), drei Typprüfungen, Klick-Beweis mit
29 Schritten inklusive Erst-Einrichtung (zweiter Start mit
RIPPY_START_BEREICH=einrichtung), Bilder beweise/klick/01…09.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-02 17:51:15 +02:00
co-authored by Claude Fable 5.1
parent 6678c81592
commit 3dc6798c79
67 changed files with 6961 additions and 3950 deletions
@@ -0,0 +1,183 @@
// Die Übersicht — Laufwerke, Warteschlange, Speicher und Bilanz, Verlauf,
// Ereignisse. Und die Dialoge: Sie leben im Zustand DIESES Bereichs, nicht
// in der Kachel (5.4.0: ein leerer Takt der Wache räumte sonst Kachel UND
// Dialog ab).
import { useState } from 'react'
import type { HauptStatus } from '../../gemeinsam/nachrichten'
import { IconDisc, LAUFENDE_PHASEN } from '../bausteine'
import { AendernDialog } from '../dialoge/AendernDialog'
import { TitelWahlDialog } from '../dialoge/TitelWahlDialog'
import { automatikStopp, titelLesen, type Verbindung } from '../kernverbindung'
import { KompressionsKarte } from './KompressionsKarte'
import { LaufwerkKachel } from './LaufwerkKachel'
import { SpeicherKarte } from './SpeicherKarte'
import { VerlaufKarte } from './VerlaufKarte'
export function Uebersicht({
verbindung,
haupt,
systemFehler,
aufRohDateien,
}: {
verbindung: Verbindung
haupt: HauptStatus | null
systemFehler: string[]
aufRohDateien: () => void
}) {
// Sagt ein Update-Knopf NEIN, steht der Grund daneben (R4) — ein Knopf,
// der nichts tut und nichts sagt, ist schlimmer als kein Knopf.
const [updateHinweis, setUpdateHinweis] = useState('')
const [dialog, setDialog] = useState<{ art: 'titel' | 'aendern'; id: string } | null>(null)
const einRipLaeuft = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
return (
<>
{systemFehler.length > 0 && (
<div className="rounded-2xl border border-rot/40 bg-rot/10 p-4">
{systemFehler.map((zeile, i) => (
<p key={i} className="break-all font-mono text-xs text-rot">
{zeile}
</p>
))}
</div>
)}
{/* § 6.8: Ein verfügbares Update ist eine SICHTBARE Karte, kein
Satz in den Einstellungen. Der Commander musste es am
01.09.2026 suchen — gefunden hat er es nicht. */}
{haupt !== null && haupt.update.neueVersion.length > 0 && (
<div className="rounded-2xl border border-gold/40 bg-gold/10 p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="font-display text-lg text-gold-hell">Update verfügbar Version {haupt.update.neueVersion}</span>
<span className="font-mono text-xs text-nebel">du hast {haupt.version}</span>
</div>
<p className="mt-1 font-mono text-xs text-dunst">{haupt.update.text}</p>
{haupt.update.fortschritt >= 0 && !haupt.update.bereit && (
<div className="mt-2 h-1.5 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full rounded-full bg-gold transition-all" style={{ width: `${Math.max(0, Math.min(100, haupt.update.fortschritt))}%` }} />
</div>
)}
{haupt.update.neuerungen.length > 0 && (
<div className="mt-3 rounded-xl border border-white/8 bg-buehne/60 p-3">
<p className="text-[11px] uppercase tracking-wider text-nebel">Was neu ist</p>
<p className="mt-1 whitespace-pre-wrap font-mono text-xs leading-5 text-dunst">{haupt.update.neuerungen}</p>
</div>
)}
{haupt.update.bereit && (
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={() => {
setUpdateHinweis('')
void window.rippy.updateInstallieren().then((urteil) => {
if (!urteil.ok) setUpdateHinweis(urteil.grund)
})
}}
disabled={einRipLaeuft}
className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1.5 text-xs font-semibold text-gold-hell hover:bg-gold/25 disabled:opacity-35"
title={einRipLaeuft ? 'Erst wenn der Rip fertig ist — Rippy startet dafür neu.' : 'Rippy beendet sich, installiert und startet wieder.'}
>
Jetzt neu starten und installieren
</button>
<span className="font-mono text-[11px] text-nebel"> oder einfach nichts tun: Beim nächsten Beenden passiert es von selbst.</span>
</div>
)}
{updateHinweis.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{updateHinweis}</p>}
</div>
)}
<section>
{verbindung.laufwerke.length === 0 ? (
<div className="flex items-center gap-5 rounded-2xl border border-white/10 bg-karte/50 p-6">
<IconDisc groesse={44} farbe="oklch(0.4 0.02 75)" />
<p className="font-mono text-xs text-nebel">{verbindung.verbunden ? 'Kein optisches Laufwerk gefunden.' : 'Warte auf den Kern …'}</p>
</div>
) : (
<div className="space-y-4">
{verbindung.laufwerke.map((geraet) => {
const ripVorgangId = verbindung.rips[geraet.id]?.vorgangId ?? ''
const laeuft = verbindung.vorgaenge?.kompression.laeuft ?? null
const wartend = verbindung.vorgaenge?.kompression.wartend ?? []
return (
<LaufwerkKachel
key={geraet.id}
geraet={geraet}
rip={verbindung.rips[geraet.id]}
info={verbindung.discInfos[geraet.id]}
ripStartMs={verbindung.ripStart[geraet.id]}
protokoll={verbindung.ripProtokoll[geraet.id] ?? []}
verbindung={verbindung}
bericht={verbindung.gesundheit[geraet.id]}
vorgang={verbindung.vorgaenge?.vorgaenge.find((v) => v.id === ripVorgangId)}
kompression={laeuft !== null && laeuft.vorgangId === ripVorgangId ? laeuft : null}
wartePlatz={wartend.findIndex((w) => w.vorgangId === ripVorgangId) + 1}
automatik={verbindung.automatik[geraet.id]}
aufRippen={() => {
// Der Nutzer entscheidet selbst — ein Countdown endet.
automatikStopp(geraet.id)
// Erst die Titel lesen (20-120 s), dann waehlen lassen.
setDialog({ art: 'titel', id: geraet.id })
titelLesen(geraet.id)
}}
aufAendern={() => {
automatikStopp(geraet.id)
setDialog({ art: 'aendern', id: geraet.id })
}}
/>
)
})}
</div>
)}
</section>
{verbindung.vorgaenge !== null && (
<section>
<KompressionsKarte stand={verbindung.vorgaenge.kompression} />
</section>
)}
<section>
<SpeicherKarte speicher={verbindung.speicher} statistik={verbindung.vorgaenge?.statistik ?? null} />
</section>
{verbindung.vorgaenge !== null && (
<section>
<VerlaufKarte vorgaenge={verbindung.vorgaenge.vorgaenge} aufAlle={aufRohDateien} />
</section>
)}
{dialog !== null && dialog.art === 'titel' && (
<TitelWahlDialog
id={dialog.id}
typ={verbindung.laufwerke.find((l) => l.id === dialog.id)?.typ ?? 'bluray'}
stand={verbindung.discTitel[dialog.id]}
info={verbindung.discInfos[dialog.id]}
einstellungen={verbindung.einstellungen}
regel={verbindung.vorgaenge?.regel ?? ''}
speicher={verbindung.speicher}
schliessen={() => setDialog(null)}
/>
)}
{dialog !== null && dialog.art === 'aendern' && (
<AendernDialog
id={dialog.id}
startText={verbindung.discInfos[dialog.id]?.titel ?? ''}
vorschlaege={verbindung.vorschlaege[dialog.id] ?? []}
schliessen={() => setDialog(null)}
/>
)}
{verbindung.meldungen.length > 0 && (
<section>
<div className="mb-2.5 font-mono text-[10px] tracking-[0.22em] text-nebel">EREIGNISSE</div>
<div className="max-h-56 space-y-1 overflow-y-auto rounded-2xl border border-white/10 bg-karte/50 p-4">
{verbindung.meldungen.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[11px] text-dunst">
{zeile}
</p>
))}
</div>
</section>
)}
</>
)
}