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:
co-authored by
Claude Fable 5.1
parent
6678c81592
commit
3dc6798c79
@@ -0,0 +1,83 @@
|
||||
// Die Einstellungen — Sektionswahl links (oder oben im schmalen Fenster),
|
||||
// die Karten rechts. Jede Karte ist eine Datei in Karten.tsx (5.5.0).
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { HauptStatus } from '../../gemeinsam/nachrichten'
|
||||
import type { Verbindung } from '../kernverbindung'
|
||||
import {
|
||||
AblageKarte,
|
||||
AllgemeinKarte,
|
||||
AutomatikKarte,
|
||||
BenachrichtigungKarte,
|
||||
KompressionKarte,
|
||||
MakemkvKarte,
|
||||
MedienserverKarte,
|
||||
MetadatenKarte,
|
||||
RohDateienKarte,
|
||||
UpdateKarte,
|
||||
WerkzeugeKarte,
|
||||
} from './Karten'
|
||||
|
||||
const SEKTIONEN: Array<[string, string]> = [
|
||||
['ablage', 'Ablage'],
|
||||
['kompression', 'Kompression'],
|
||||
['roh', 'Roh-Dateien'],
|
||||
['automatik', 'Automatik'],
|
||||
['metadaten', 'Metadaten'],
|
||||
['makemkv', 'MakeMKV'],
|
||||
['medienserver', 'Medienserver'],
|
||||
['benachrichtigung', 'Benachrichtigung'],
|
||||
['allgemein', 'Allgemein'],
|
||||
['update', 'Update'],
|
||||
['werkzeuge', 'Werkzeuge'],
|
||||
]
|
||||
|
||||
export function Einstellungen({ verbindung, haupt, start }: { verbindung: Verbindung; haupt: HauptStatus | null; start?: string }) {
|
||||
const [aktiv, setAktiv] = useState(start ?? 'ablage')
|
||||
|
||||
const springe = (id: string): void => {
|
||||
setAktiv(id)
|
||||
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
// „alle unter Roh-Dateien →" aus der Übersicht springt direkt zur Karte.
|
||||
useEffect(() => {
|
||||
if (start !== undefined) setTimeout(() => springe(start), 50)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [start])
|
||||
|
||||
// Schmales Fenster: die Sektionswahl legt sich als Zeile über die Karten.
|
||||
// Ab lg steht sie wieder als mitlaufende Spalte daneben (Commander-Fund
|
||||
// 01.09.2026: „alles gequetscht").
|
||||
return (
|
||||
<div className="flex flex-col gap-5 lg:flex-row lg:gap-8">
|
||||
<nav className="flex flex-row flex-wrap gap-1 lg:sticky lg:top-4 lg:h-fit lg:w-44 lg:flex-none lg:flex-col">
|
||||
{SEKTIONEN.map(([id, name]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => springe(id)}
|
||||
className={`rounded-lg px-3 py-1.5 text-left text-[13px] ${
|
||||
aktiv === id ? 'border border-gold/40 bg-gold/12 font-semibold text-gold-hell' : 'border border-transparent text-dunst hover:bg-white/5 hover:text-schnee'
|
||||
}`}
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Kein max-w mehr: Der 672-px-Deckel war der Grund, warum in einem
|
||||
breiten Fenster alles links klebte und rechts nichts stand. */}
|
||||
<div className="min-w-0 flex-1 space-y-5">
|
||||
<AblageKarte verbindung={verbindung} />
|
||||
<KompressionKarte verbindung={verbindung} />
|
||||
<RohDateienKarte verbindung={verbindung} aktivFuer={start === 'roh'} />
|
||||
<AutomatikKarte verbindung={verbindung} />
|
||||
<MetadatenKarte verbindung={verbindung} />
|
||||
<MakemkvKarte verbindung={verbindung} />
|
||||
<MedienserverKarte verbindung={verbindung} />
|
||||
<BenachrichtigungKarte verbindung={verbindung} />
|
||||
<AllgemeinKarte verbindung={verbindung} haupt={haupt} />
|
||||
<UpdateKarte verbindung={verbindung} haupt={haupt} />
|
||||
<WerkzeugeKarte verbindung={verbindung} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
// Die Einstellungs-Karten (5.5.0, je Karte eine Funktion): Ablage,
|
||||
// Kompression, Roh-Dateien, Automatik, Metadaten, MakeMKV, Medienserver,
|
||||
// Benachrichtigung, Allgemein, Update, Werkzeuge. Alle schreiben über
|
||||
// einstellungSetzen in die EINE Quelle (§ 6.7).
|
||||
import { useState } from 'react'
|
||||
import type { GeraeteInfo, HauptStatus, VorgangEintrag } from '../../gemeinsam/nachrichten'
|
||||
import { presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
import {
|
||||
Badge,
|
||||
FELD_KLASSE,
|
||||
Feld,
|
||||
HinterlegtPunkt,
|
||||
KNOPF_LEISE,
|
||||
LAUFENDE_PHASEN,
|
||||
PfadFeld,
|
||||
Schalter,
|
||||
SektionsKarte,
|
||||
TYP_NAMEN,
|
||||
datumKurz,
|
||||
gb,
|
||||
gbKurz,
|
||||
} from '../bausteine'
|
||||
import {
|
||||
benachrichtigungTesten,
|
||||
einstellungSetzen,
|
||||
fremdenOrdnerLoeschen,
|
||||
kompressionAbbrechen,
|
||||
makemkvUpdate,
|
||||
rohAufraeumen,
|
||||
schluesselPruefen,
|
||||
vorgaengeLaden,
|
||||
vorgangNeuKomprimieren,
|
||||
vorgangRohLoeschen,
|
||||
type Verbindung,
|
||||
} from '../kernverbindung'
|
||||
import { phasenTon } from '../uebersicht/VerlaufKarte'
|
||||
import { SprachAuswahl } from './SprachAuswahl'
|
||||
|
||||
const setzen = einstellungSetzen
|
||||
|
||||
export function AblageKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const speicher = verbindung.speicher
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="ablage"
|
||||
titel="ABLAGE"
|
||||
intro="Die Ablage ist, wo Fertiges landet — auch ein Netzlaufwerk. Der Arbeitsordner ist, wo Rippy rechnet: Roh-Dateien und Kompression. Liegt die Ablage auf dem Server, gehört der Arbeitsordner auf eine lokale Platte."
|
||||
>
|
||||
<PfadFeld name="Ablage-Ordner" wert={werte['ablage'] ?? ''} art="ordner" dialogTitel="Ablage-Ordner wählen" aufAendern={(w) => setzen('ablage', w)} />
|
||||
{speicher !== null && speicher.ablage.ok && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
{gbKurz(speicher.ablage.freiBytes)} frei von {gbKurz(speicher.ablage.gesamtBytes)}
|
||||
</p>
|
||||
)}
|
||||
<PfadFeld
|
||||
name="Arbeitsordner (lokal, optional)"
|
||||
hinweis="leer = unter der Ablage; Roh-Dateien und Kompression liegen dann dort"
|
||||
wert={werte['arbeitsordner'] ?? ''}
|
||||
art="ordner"
|
||||
dialogTitel="Arbeitsordner wählen (lokale Platte)"
|
||||
aufAendern={(w) => setzen('arbeitsordner', w)}
|
||||
/>
|
||||
{speicher !== null && speicher.getrennt && speicher.arbeit.ok && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
{gbKurz(speicher.arbeit.freiBytes)} frei von {gbKurz(speicher.arbeit.gesamtBytes)} — eine Blu-ray braucht rund 50 GB.
|
||||
</p>
|
||||
)}
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p className="text-dunst">So legt Rippy ab:</p>
|
||||
<p>
|
||||
├─ Filme\Akira (1988)\ <span className="text-nebel/70">— Film, Poster und Infodatei beieinander</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ Serien\Spartacus\Season 01\ <span className="text-nebel/70">— Folgen, wie Jellyfin sie erwartet</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ Musik\ <span className="text-nebel/70">— Audio-CDs als FLAC, benannt nach MusicBrainz</span>
|
||||
</p>
|
||||
<p>
|
||||
├─ ISO\ <span className="text-nebel/70">— unerkannte Discs als 1:1-Abbild</span>
|
||||
</p>
|
||||
<p>
|
||||
└─ roh\ <span className="text-nebel/70">— im Arbeitsordner: verlustfreies Roh-Material, aufgeräumt nach der Regel unter „Roh-Dateien"</span>
|
||||
</p>
|
||||
</div>
|
||||
<Schalter
|
||||
an={werte['autoAuswurf'] !== 'false'}
|
||||
aufWechsel={(an) => setzen('autoAuswurf', an ? 'true' : 'false')}
|
||||
label="Disc nach dem Rip auswerfen — die nächste kann rein, während die Kompression läuft"
|
||||
/>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
function PresetWahl({
|
||||
name,
|
||||
schluessel,
|
||||
wert,
|
||||
presets,
|
||||
empfehlung,
|
||||
}: {
|
||||
name: string
|
||||
schluessel: string
|
||||
wert: string
|
||||
presets: string[]
|
||||
empfehlung: string
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||
<select value={wert} onChange={(e) => setzen(schluessel, e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="">{empfehlung.length > 0 ? `automatisch: ${empfehlung}` : 'automatisch'}</option>
|
||||
<option value="keine">NICHT komprimieren (verlustfrei lassen)</option>
|
||||
{presets.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{preset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function KompressionKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const presets = verbindung.werkzeuge?.presets ?? []
|
||||
const backends = verbindung.werkzeuge?.backends ?? []
|
||||
const empfehlungFuer = (typ: string): string => presetEmpfehlung(typ, backends, presets)
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="kompression"
|
||||
titel="KOMPRESSION"
|
||||
intro="Nach dem verlustfreien Rip rechnet HandBrake die Filme auf Arbeitsgröße — der Rohling bleibt bis zum Erfolg liegen, es geht nie etwas verloren."
|
||||
>
|
||||
{verbindung.werkzeuge !== null && verbindung.werkzeuge.handbrake.length === 0 && (
|
||||
<p className="font-mono text-xs text-warn">⚠ HandBrakeCLI fehlt — Rippen geht, Komprimieren nicht (siehe Werkzeuge).</p>
|
||||
)}
|
||||
<Schalter an={werte['transcodeEnabled'] !== 'false'} aufWechsel={(an) => setzen('transcodeEnabled', an ? 'true' : 'false')} label="Nach dem Rip automatisch komprimieren" />
|
||||
{backends.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<span className="font-mono text-[10px] tracking-wider text-nebel">GEMESSENE ENCODER:</span>
|
||||
{backends.map((backend) => (
|
||||
<Badge key={backend} text={backend} ton={backend.startsWith('cpu') ? 'grau' : 'blau'} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<PresetWahl name="DVD" schluessel="transcodePresetDvd" wert={werte['transcodePresetDvd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('dvd')} />
|
||||
<PresetWahl name="Blu-ray" schluessel="transcodePresetBluray" wert={werte['transcodePresetBluray'] ?? ''} presets={presets} empfehlung={empfehlungFuer('bluray')} />
|
||||
<PresetWahl name="4K-UHD" schluessel="transcodePresetUhd" wert={werte['transcodePresetUhd'] ?? ''} presets={presets} empfehlung={empfehlungFuer('uhd')} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
„automatisch" nimmt die Empfehlung aus der gemessenen Liste deines HandBrake — auf dieser Maschine die Grafikkarte, wo es sie gibt. Eine eigene Wahl
|
||||
schlägt die Empfehlung immer.
|
||||
</p>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<SprachAuswahl
|
||||
name="Tonspuren"
|
||||
hinweis="Reihenfolge = Wunschreihenfolge; die erste ist deine Sprache"
|
||||
wert={werte['audioSprachen'] ?? ''}
|
||||
aufAendern={(w) => setzen('audioSprachen', w)}
|
||||
/>
|
||||
<SprachAuswahl name="Untertitel" hinweis="werden vorgewählt, wenn der Ton nicht deine Sprache ist" wert={werte['untertitelSprachen'] ?? ''} aufAendern={(w) => setzen('untertitelSprachen', w)} />
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function AutomatikKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const an = werte['automatik'] !== 'aus'
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="automatik"
|
||||
titel="AUTOMATIK"
|
||||
intro="Disc rein genügt: Ist Rippy sicher, was das ist, liest es die Titel und startet nach einem Countdown von selbst — mit seinem Vorschlag. Wo es unsicher ist, wartet es auf dich."
|
||||
>
|
||||
<Schalter an={an} aufWechsel={(a) => setzen('automatik', a ? 'an' : 'aus')} label="Automatik: bei sicherer Erkennung (ab 90 %) von selbst rippen" />
|
||||
<label className="block max-w-xs">
|
||||
<span className="text-xs font-semibold text-dunst">Countdown</span>
|
||||
<select value={werte['automatikSekunden'] ?? '30'} onChange={(e) => setzen('automatikSekunden', e.target.value)} disabled={!an} className={FELD_KLASSE}>
|
||||
<option value="10">10 Sekunden</option>
|
||||
<option value="30">30 Sekunden (Vorgabe)</option>
|
||||
<option value="60">1 Minute</option>
|
||||
<option value="120">2 Minuten</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
Der Countdown steht in der Laufwerks-Kachel, mit „Stopp" und „Jetzt". Eine Disc, die schon einmal gerippt wurde, startet nie von selbst.
|
||||
</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function RohDateienKarte({ verbindung, aktivFuer }: { verbindung: Verbindung; aktivFuer?: boolean }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const v = verbindung.vorgaenge
|
||||
const [presetWahl, setPresetWahl] = useState<Record<string, string>>({})
|
||||
const [alleZeigen, setAlleZeigen] = useState(aktivFuer === true)
|
||||
const presets = verbindung.werkzeuge?.presets ?? []
|
||||
const regelWert = werte['rohAufbewahrung'] ?? '7'
|
||||
const phasenText: Record<VorgangEintrag['phase'], string> = {
|
||||
rippt: 'rippt',
|
||||
wartet: 'wartet auf Kompression',
|
||||
komprimiert: 'komprimiert',
|
||||
'legt-ab': 'legt ab',
|
||||
fertig: 'fertig',
|
||||
fehler: 'Fehler',
|
||||
abgebrochen: 'abgebrochen',
|
||||
}
|
||||
const liste = v?.vorgaenge ?? []
|
||||
const sichtbar = alleZeigen ? liste : liste.slice(0, 12)
|
||||
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="roh"
|
||||
titel="ROH-DATEIEN"
|
||||
intro="Jede Blu-ray lässt 30 GB und mehr verlustfreies Roh-Material liegen — genau dafür, dass ein Preset-Wechsel kein Neulesen kostet. Aufgeräumt wird nach Regel, und nur, wenn das fertige Ergebnis nachweislich da ist."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_auto] sm:items-end">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">Nach gelungener Kompression</span>
|
||||
<select value={regelWert} onChange={(e) => setzen('rohAufbewahrung', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="sofort">sofort löschen</option>
|
||||
<option value="7">nach 7 Tagen löschen (Vorgabe)</option>
|
||||
<option value="14">nach 14 Tagen löschen</option>
|
||||
<option value="30">nach 30 Tagen löschen</option>
|
||||
<option value="behalten">behalten — nur von Hand löschen</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => rohAufraeumen()} className={`${KNOPF_LEISE} py-2`} title="Die Regel jetzt anwenden, statt auf den täglichen Takt zu warten">
|
||||
Jetzt aufräumen
|
||||
</button>
|
||||
<button onClick={() => vorgaengeLaden()} className={`${KNOPF_LEISE} py-2`} title="Belegung neu messen">
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
{v === null ? (
|
||||
<p>Belegung wird gemessen …</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<span className="text-dunst">Belegung jetzt:</span> <span className="text-gold-hell">{gb(v.rohGesamtBytes)}</span> in{' '}
|
||||
{liste.filter((e) => !e.rohGeloescht).length + v.fremde.length} Roh-Ordnern
|
||||
</p>
|
||||
<p>{v.regel}</p>
|
||||
<p className="text-nebel/70">Unkomprimierte Rips (z. B. 4K verlustfrei) liegen im Zielordner, nicht hier — sie sind nie betroffen.</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{liste.length === 0 && v !== null && <p className="font-mono text-xs text-nebel">Noch kein Vorgang — der erste Rip legt hier einen Eintrag an.</p>}
|
||||
<div className="space-y-2">
|
||||
{sichtbar.map((e) => {
|
||||
const ruhig = e.phase === 'fertig' || e.phase === 'fehler' || e.phase === 'abgebrochen'
|
||||
return (
|
||||
<div key={e.id} className="rounded-xl border border-white/8 bg-buehne/60 p-3">
|
||||
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
|
||||
<span className="font-display text-[14px] font-bold text-schnee">
|
||||
{e.titel}
|
||||
{e.jahr !== null && <span className="ml-1 font-medium text-dunst">({e.jahr})</span>}
|
||||
</span>
|
||||
<Badge text={phasenText[e.phase]} ton={phasenTon(e.phase)} />
|
||||
<span className="font-mono text-[10.5px] text-nebel">
|
||||
{(TYP_NAMEN[e.typ as GeraeteInfo['typ']] ?? e.typ).toUpperCase()} · {datumKurz(e.begonnen)}
|
||||
{e.titelZahl > 0 && ` · ${e.titelZahl} Titel`}
|
||||
{e.preset.length > 0 && ` · ${e.preset}`}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 font-mono text-[11px] leading-5 text-nebel">
|
||||
<span className="text-dunst">Roh:</span> {e.rohGeloescht ? 'gelöscht' : gb(e.rohBytes)}
|
||||
{e.faelligAm.length > 0 && ` · fällig ${datumKurz(e.faelligAm)}`}
|
||||
{' · '}
|
||||
<span className="text-dunst">Fertig:</span>{' '}
|
||||
{e.phase === 'fertig' ? (
|
||||
<span className="break-all">
|
||||
{e.fertigBytes > 0 ? `${gb(e.fertigBytes)} · ` : ''}
|
||||
{e.zielOrdner}
|
||||
{!e.komprimiert && ' (verlustfrei, ohne Kompression)'}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</p>
|
||||
{e.fehler.length > 0 && <p className="mt-1 break-all font-mono text-[11px] text-warn">⚠ {e.fehler}</p>}
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
{e.neuKomprimierbar && (
|
||||
<>
|
||||
<select
|
||||
value={presetWahl[e.id] ?? ''}
|
||||
onChange={(ev) => setPresetWahl({ ...presetWahl, [e.id]: ev.target.value })}
|
||||
className="rounded-lg border border-white/15 bg-samt/70 px-2 py-1 text-[11px] text-dunst"
|
||||
title="Preset für die Wiederholung — leer: wie eingestellt"
|
||||
>
|
||||
<option value="">Preset wie eingestellt</option>
|
||||
{presets.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => vorgangNeuKomprimieren(e.id, presetWahl[e.id] ?? '')}
|
||||
className="rounded-lg border border-gold/40 bg-gold/12 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/20"
|
||||
title="Die Roh-Dateien liegen genau dafür da — kein Neulesen der Disc"
|
||||
>
|
||||
Neu komprimieren
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{!e.rohGeloescht && ruhig && (
|
||||
<button onClick={() => vorgangRohLoeschen(e.id)} className="rounded-lg border border-rot/40 bg-rot/10 px-3 py-1 text-[11px] text-rot hover:bg-rot/20" title="Den Roh-Ordner dieses Vorgangs jetzt löschen">
|
||||
Roh-Dateien löschen
|
||||
</button>
|
||||
)}
|
||||
{e.phase === 'fertig' && e.zielOrdner.length > 0 && (
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(e.zielOrdner)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
|
||||
Ordner öffnen
|
||||
</button>
|
||||
)}
|
||||
{(e.phase === 'wartet' || e.phase === 'komprimiert' || e.phase === 'legt-ab') && (
|
||||
<button onClick={() => kompressionAbbrechen(e.id)} className="rounded-lg border border-white/15 px-3 py-1 text-[11px] text-dunst hover:text-rot">
|
||||
Kompression abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{liste.length > sichtbar.length && (
|
||||
<button onClick={() => setAlleZeigen(true)} className="font-mono text-[10.5px] text-nebel hover:text-dunst">
|
||||
▸ alle {liste.length} Vorgänge zeigen
|
||||
</button>
|
||||
)}
|
||||
|
||||
{v !== null && v.fremde.length > 0 && (
|
||||
<div className="rounded-xl border border-warn/30 bg-warn/5 p-3">
|
||||
<p className="font-mono text-[10.5px] tracking-wider text-warn">ORDNER OHNE VORGANG</p>
|
||||
<p className="mt-1 text-[11px] leading-4 text-nebel">Aus der Zeit vor 5.4.0 oder von Hand hineingelegt — Rippy kennt sie nicht und löscht sie deshalb NIE von selbst.</p>
|
||||
<div className="mt-2 space-y-1">
|
||||
{v.fremde.map((f) => (
|
||||
<div key={f.ordner} className="flex items-center justify-between gap-3">
|
||||
<span className="min-w-0 break-all font-mono text-[11px] text-dunst">
|
||||
{f.ordner} · {gb(f.bytes)}
|
||||
</span>
|
||||
<button onClick={() => fremdenOrdnerLoeschen(f.ordner)} className="flex-none rounded-lg border border-rot/40 bg-rot/10 px-2.5 py-0.5 text-[10.5px] text-rot hover:bg-rot/20">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function MetadatenKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="metadaten"
|
||||
titel="METADATEN"
|
||||
intro="TMDb erkennt Titel, Jahr, Poster, Logo und Beschreibung; OMDb hilft bei Ausreißern nach. Beide Schlüssel sind kostenlos und in zwei Minuten geholt."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Feld name="TMDb-API-Key" geheim wert={werte['tmdbApiKey'] ?? ''} aufAendern={(w) => setzen('tmdbApiKey', w)} />
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
www.themoviedb.org → Einstellungen → API
|
||||
<HinterlegtPunkt wert={werte['tmdbApiKey'] ?? ''} />
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Feld name="OMDb-API-Key" geheim wert={werte['omdbApiKey'] ?? ''} aufAendern={(w) => setzen('omdbApiKey', w)} />
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
www.omdbapi.com/apikey.aspx
|
||||
<HinterlegtPunkt wert={werte['omdbApiKey'] ?? ''} />
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">
|
||||
Ohne Schlüssel rippt Rippy trotzdem — die Filme heißen dann wie das Disc-Label, und „Ändern …" an der Laufwerks-Kachel bleibt ohne Suche.
|
||||
</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function MakemkvKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const s = verbindung.schluessel
|
||||
const b = verbindung.beschaffung
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="makemkv"
|
||||
titel="MAKEMKV"
|
||||
intro="MakeMKV liest die Discs. Rippy holt den frei veröffentlichten Beta-Key automatisch aus dem Hersteller-Forum, hinterlegt ihn und fragt MakeMKV selbst, ob er angenommen wird — täglich."
|
||||
>
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3">
|
||||
{s === null ? (
|
||||
<p className="font-mono text-xs text-nebel">Schlüssel wird geprüft …</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="flex items-center gap-2 font-mono text-[10.5px] tracking-wider">
|
||||
<span className={`inline-block h-2 w-2 rounded-full ${s.angenommen === true ? 'bg-gruen' : s.weiterBetrieb ? 'bg-warn' : 'bg-rot'}`} />
|
||||
<span className={s.angenommen === true ? 'text-gruen' : s.weiterBetrieb ? 'text-warn' : 'text-rot'}>
|
||||
{s.angenommen === true ? 'SCHLÜSSEL ANGENOMMEN' : s.weiterBetrieb ? 'BETA-MODUS — RIPS LAUFEN' : 'AUSFALL'}
|
||||
</span>
|
||||
{s.version.length > 0 && <span className="ml-auto text-nebel">MakeMKV {s.version}</span>}
|
||||
</p>
|
||||
<p className="mt-1.5 text-xs leading-5 text-dunst">{s.text}</p>
|
||||
<p className="mt-1 font-mono text-[10px] text-nebel">
|
||||
zuletzt geprüft: {s.geholtAm.slice(0, 16).replace('T', ' ')}
|
||||
{s.gueltigBis.length > 0 && ` · Key gültig bis ${s.gueltigBis}`}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{s !== null && s.angenommen === false && (
|
||||
<button
|
||||
onClick={() => makemkvUpdate()}
|
||||
disabled={b !== null && b.laeuft}
|
||||
className="rounded-lg bg-gradient-to-b from-warn to-[oklch(0.72_0.12_78)] px-3 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.35)] hover:brightness-105 disabled:opacity-40"
|
||||
title="Holt den offiziellen Installer über die Quellenkette, prüft ihn und startet ihn — Windows fragt einmal nach Adminrechten."
|
||||
>
|
||||
MakeMKV aktualisieren …
|
||||
</button>
|
||||
)}
|
||||
<button onClick={() => schluesselPruefen()} disabled={b !== null && b.laeuft} className={KNOPF_LEISE} title="Forum-Key holen, hinterlegen und MakeMKV selbst fragen, ob er angenommen wird">
|
||||
Schlüssel neu prüfen
|
||||
</button>
|
||||
</div>
|
||||
{b !== null && (
|
||||
<div className="rounded-lg border border-white/10 bg-buehne/70 p-2.5">
|
||||
<p className={`font-mono text-[11px] leading-5 ${b.fehler ? 'text-rot' : b.laeuft ? 'text-gold-hell' : 'text-gruen'}`}>{b.text}</p>
|
||||
{b.laeuft && b.anteil !== null && (
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-white/8">
|
||||
<div className="h-full rounded-full bg-gradient-to-r from-gold-tief to-gold-hell transition-all" style={{ width: `${Math.round(b.anteil * 100)}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Feld
|
||||
name="Eigener Schlüssel (Dauerlizenz)"
|
||||
hinweis="schlägt den automatischen Beta-Key — leer lassen, wenn keiner gekauft ist"
|
||||
geheim
|
||||
wert={werte['makemkvAppKey'] ?? ''}
|
||||
aufAendern={(w) => setzen('makemkvAppKey', w)}
|
||||
/>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function MedienserverKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="medienserver"
|
||||
titel="MEDIENSERVER"
|
||||
intro="Nach jedem fertigen Rip sagt Rippy deinem Server Bescheid, damit der Film sofort auftaucht — ohne Warten auf den nächsten Bibliotheks-Scan."
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<label className="block">
|
||||
<span className="text-xs font-semibold text-dunst">System</span>
|
||||
<select value={werte['mediaServer'] ?? 'none'} onChange={(e) => setzen('mediaServer', e.target.value)} className={FELD_KLASSE}>
|
||||
<option value="none">keiner</option>
|
||||
<option value="jellyfin">Jellyfin</option>
|
||||
<option value="emby">Emby</option>
|
||||
<option value="kodi">Kodi</option>
|
||||
<option value="plex">Plex (nur Benennung)</option>
|
||||
</select>
|
||||
</label>
|
||||
<Feld name="Adresse" hinweis="http(s)://…" wert={werte['mediaServerUrl'] ?? ''} aufAendern={(w) => setzen('mediaServerUrl', w)} />
|
||||
<Feld name="API-Key" geheim hinweis="Kodi: benutzer:passwort" wert={werte['mediaServerApiKey'] ?? ''} aufAendern={(w) => setzen('mediaServerApiKey', w)} />
|
||||
</div>
|
||||
<p className="text-[11px] leading-4 text-nebel">Bei Plex benennt Rippy nur nach dessen Regeln — angestoßen wird dort nichts. Die Ordnerstruktur aus „Ablage" passt für alle vier.</p>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function BenachrichtigungKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="benachrichtigung"
|
||||
titel="BENACHRICHTIGUNG"
|
||||
intro="Rippy meldet sich aufs Handy, wenn ein Rip oder eine Kompression fertig ist oder etwas schiefgeht — über einen Discord-Webhook oder einen Telegram-Bot. Windows-Meldungen kommen immer."
|
||||
>
|
||||
<Schalter an={werte['benachrichtigungen'] !== 'aus'} aufWechsel={(an) => setzen('benachrichtigungen', an ? 'an' : 'aus')} label="Benachrichtigungen verschicken" />
|
||||
<Feld
|
||||
name="Discord-Webhook"
|
||||
hinweis="Discord → Servereinstellungen → Integrationen → Webhooks → URL kopieren"
|
||||
geheim
|
||||
wert={werte['discordWebhook'] ?? ''}
|
||||
aufAendern={(w) => setzen('discordWebhook', w)}
|
||||
/>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Feld name="Telegram-Bot-Token" hinweis="von @BotFather" geheim wert={werte['telegramToken'] ?? ''} aufAendern={(w) => setzen('telegramToken', w)} />
|
||||
<Feld name="Telegram-Chat-ID" hinweis="deine ID, z. B. von @userinfobot" wert={werte['telegramChatId'] ?? ''} aufAendern={(w) => setzen('telegramChatId', w)} />
|
||||
</div>
|
||||
<button onClick={() => benachrichtigungTesten()} className={KNOPF_LEISE}>
|
||||
Testnachricht schicken
|
||||
</button>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function AllgemeinKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const kern = verbindung.kern
|
||||
const db = kern?.datenbank ?? null
|
||||
const dbText = db === null ? '—' : db.ok ? db.pfad : `FEHLER — ${db.fehler}`
|
||||
return (
|
||||
<SektionsKarte id="allgemein" titel="ALLGEMEIN" intro="Rippy lebt im Infobereich neben der Uhr: Fenster zu heißt weiterlaufen, beendet wird über das Tray-Menü.">
|
||||
<Schalter an={haupt?.autostart === true} aufWechsel={(an) => void window.rippy.autostartSetzen(an)} label="Rippy mit Windows starten (Autostart im Benutzerkonto — kein Dienst, keine Adminrechte)" />
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => void window.rippy.protokollOeffnen()} className={KNOPF_LEISE} title="Eine Datei je Tag, 14 Tage aufgehoben — Haupt und Kern schreiben hinein">
|
||||
Protokoll öffnen
|
||||
</button>
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(werte['ablage'] ?? '')} className={KNOPF_LEISE}>
|
||||
Ablage im Explorer
|
||||
</button>
|
||||
{(werte['arbeitsordner'] ?? '').length > 0 && (
|
||||
<button onClick={() => void window.rippy.ordnerOeffnen(werte['arbeitsordner'] ?? '')} className={KNOPF_LEISE}>
|
||||
Arbeitsordner im Explorer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p>
|
||||
Rippy v{haupt?.version ?? '…'}
|
||||
{kern !== null && ` · Kern: Node ${kern.nodeVersion} (PID ${kern.pid})`}
|
||||
{haupt !== null && ` · Leine ${haupt.leine.ok ? 'gesetzt' : 'NICHT gesetzt'}`}
|
||||
{verbindung.pongLatenzMs !== null && ` · Antwortzeit ${verbindung.pongLatenzMs} ms`}
|
||||
</p>
|
||||
<p className="break-all">Datenbank: {dbText}</p>
|
||||
<p className="break-all">Protokoll: {haupt?.protokollOrdner ?? '—'}</p>
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
export function UpdateKarte({ verbindung, haupt }: { verbindung: Verbindung; haupt: HauptStatus | null }) {
|
||||
// § 6.8: Der Prüf-Knopf ist während eines Rips aus — nicht, weil er
|
||||
// nicht könnte, sondern weil Rippy dann bewusst nicht sucht.
|
||||
const ripLaeuftGerade = Object.values(verbindung.rips).some((r) => LAUFENDE_PHASEN.includes(r.phase))
|
||||
return (
|
||||
<SektionsKarte id="update" titel="UPDATE" intro="Rippy holt neue Fassungen selbst aus dem Gitea — geprüft, geladen, beim nächsten Beenden installiert. Nie mitten in einem Rip oder einer Kompression.">
|
||||
{/* Das Feld „Update-Adresse" ist am 01.09.2026 auf Zuruf des
|
||||
Commanders verschwunden: Updates kommen immer vom Gitea, ein
|
||||
Eingabefeld dafür war nur Ballast. Der Einstellungs-Schlüssel
|
||||
`updateUrl` gilt weiterhin — wer ihn wirklich braucht, setzt
|
||||
ihn in der Datenbank, und § 6.8 prüft ihn wie zuvor (nur
|
||||
https://). */}
|
||||
{haupt !== null && (
|
||||
<div className="rounded-xl border border-white/8 bg-buehne/60 p-3 font-mono text-[11px] leading-5 text-nebel">
|
||||
<p>
|
||||
<span className="text-dunst">Stand:</span> {haupt.update.text}
|
||||
</p>
|
||||
{haupt.update.letzteErfolgreichePruefung.length > 0 && <p>letzte erfolgreiche Prüfung: {haupt.update.letzteErfolgreichePruefung.slice(0, 16).replace('T', ' ')}</p>}
|
||||
{haupt.update.neueVersion.length > 0 && <p className="text-gold-hell">Version {haupt.update.neueVersion} gefunden — die Karte in der Übersicht zeigt, was neu ist.</p>}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
onClick={() => void window.rippy.updatePruefen()}
|
||||
disabled={ripLaeuftGerade || haupt?.update.laeuft === true}
|
||||
className={KNOPF_LEISE}
|
||||
title={ripLaeuftGerade ? 'Während eines Rips wird nicht gesucht — das ist Absicht (§ 6.8).' : 'Fragt sofort beim Update-Server nach.'}
|
||||
>
|
||||
{haupt?.update.laeuft === true ? 'sucht …' : 'Jetzt auf Updates prüfen'}
|
||||
</button>
|
||||
</div>
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
|
||||
function WerkzeugZeile({
|
||||
name,
|
||||
pfad,
|
||||
fehlt,
|
||||
pflicht,
|
||||
schluessel,
|
||||
wert,
|
||||
}: {
|
||||
name: string
|
||||
pfad: string | null
|
||||
fehlt: string
|
||||
pflicht: boolean
|
||||
schluessel: string
|
||||
wert: string
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/8 bg-samt/50 p-3">
|
||||
<p className={`break-all font-mono text-xs ${pfad === null ? 'text-nebel' : pfad.length > 0 ? 'text-gruen' : pflicht ? 'text-rot' : 'text-warn'}`}>
|
||||
{name}: {pfad === null ? 'wird gesucht …' : pfad.length > 0 ? pfad : fehlt}
|
||||
</p>
|
||||
<PfadFeld
|
||||
name="Eigener Pfad"
|
||||
hinweis="leer = automatische Suche; ein eingetragener Pfad gilt exakt"
|
||||
wert={wert}
|
||||
art="programm"
|
||||
dialogTitel={`${name} wählen`}
|
||||
aufAendern={(w) => setzen(schluessel, w)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function WerkzeugeKarte({ verbindung }: { verbindung: Verbindung }) {
|
||||
const werte = verbindung.einstellungen
|
||||
const backends = verbindung.werkzeuge?.backends ?? []
|
||||
return (
|
||||
<SektionsKarte
|
||||
id="werkzeuge"
|
||||
titel="WERKZEUGE"
|
||||
intro="Rippy findet seine Werkzeuge von allein: mitgeliefert, selbst geholt, installiert oder im PATH — in dieser Reihenfolge. Wer einen eigenen Pfad einträgt, bekommt genau den."
|
||||
>
|
||||
<WerkzeugZeile
|
||||
name="makemkvcon"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.makemkvcon}
|
||||
fehlt="NICHT GEFUNDEN — MakeMKV von makemkv.com installieren, Rippy findet es danach von allein"
|
||||
pflicht={true}
|
||||
schluessel="werkzeug.makemkv"
|
||||
wert={werte['werkzeug.makemkv'] ?? ''}
|
||||
/>
|
||||
<WerkzeugZeile
|
||||
name="HandBrakeCLI"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.handbrake}
|
||||
fehlt="nicht gefunden — Rippen geht, Kompression nicht"
|
||||
pflicht={false}
|
||||
schluessel="werkzeug.handbrake"
|
||||
wert={werte['werkzeug.handbrake'] ?? ''}
|
||||
/>
|
||||
<WerkzeugZeile
|
||||
name="flac"
|
||||
pfad={verbindung.werkzeuge === null ? null : verbindung.werkzeuge.flac}
|
||||
fehlt="nicht gefunden — nur für Audio-CDs nötig"
|
||||
pflicht={false}
|
||||
schluessel="werkzeug.flac"
|
||||
wert={werte['werkzeug.flac'] ?? ''}
|
||||
/>
|
||||
{verbindung.werkzeuge !== null && (
|
||||
<p className="font-mono text-[10.5px] text-nebel">
|
||||
HandBrake gemessen: {verbindung.werkzeuge.presets.length} Presets · Encoder: {backends.length > 0 ? backends.join(', ') : '—'}
|
||||
</p>
|
||||
)}
|
||||
</SektionsKarte>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
// Die Sprach-Auswahl (5.5.0, Commander: „bei Tonspuren und Untertiteln
|
||||
// können Dropdowns hin") — gewählte Sprachen als Chips in Wunschreihenfolge,
|
||||
// dazu ein Dropdown zum Hinzufügen. Die erste Tonsprache ist die
|
||||
// Wunschsprache (auftrag.ts sprachwahl). Leer heißt „alle".
|
||||
import { SPRACHEN, sprachName, sprachlisteAus, sprachlisteText } from '../../gemeinsam/sprachen'
|
||||
|
||||
export function SprachAuswahl({
|
||||
name,
|
||||
hinweis,
|
||||
wert,
|
||||
aufAendern,
|
||||
}: {
|
||||
name: string
|
||||
hinweis: string
|
||||
wert: string
|
||||
aufAendern: (wert: string) => void
|
||||
}) {
|
||||
const gewaehlt = sprachlisteAus(wert)
|
||||
const frei = SPRACHEN.filter((s) => !gewaehlt.includes(s.code))
|
||||
const setzen = (codes: string[]): void => aufAendern(sprachlisteText(codes))
|
||||
const verschieben = (code: string, richtung: -1 | 1): void => {
|
||||
const i = gewaehlt.indexOf(code)
|
||||
const j = i + richtung
|
||||
if (i < 0 || j < 0 || j >= gewaehlt.length) return
|
||||
const neu = [...gewaehlt]
|
||||
;[neu[i], neu[j]] = [neu[j], neu[i]]
|
||||
setzen(neu)
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-dunst">{name}</span>
|
||||
<span className="ml-2 text-xs text-nebel">{hinweis}</span>
|
||||
<div className="mt-1 flex min-h-[38px] flex-wrap items-center gap-1.5 rounded-lg border border-white/12 bg-samt px-2 py-1.5">
|
||||
{gewaehlt.length === 0 && <span className="font-mono text-[11px] text-nebel">alle Sprachen der Disc</span>}
|
||||
{gewaehlt.map((code, i) => (
|
||||
<span key={code} className="inline-flex items-center gap-1 rounded-md border border-gold/40 bg-gold/12 px-2 py-0.5 text-[11px] text-gold-hell">
|
||||
<span className="font-mono text-[10px] text-gold/70">{i + 1}.</span>
|
||||
<span>{sprachName(code)}</span>
|
||||
<span className="font-mono text-[10px] text-nebel">{code}</span>
|
||||
<button type="button" onClick={() => verschieben(code, -1)} disabled={i === 0} className="ml-1 text-nebel hover:text-schnee disabled:opacity-30" title="nach vorn">
|
||||
‹
|
||||
</button>
|
||||
<button type="button" onClick={() => verschieben(code, 1)} disabled={i === gewaehlt.length - 1} className="text-nebel hover:text-schnee disabled:opacity-30" title="nach hinten">
|
||||
›
|
||||
</button>
|
||||
<button type="button" onClick={() => setzen(gewaehlt.filter((c) => c !== code))} className="ml-0.5 text-nebel hover:text-rot" title="entfernen">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value.length > 0) setzen([...gewaehlt, e.target.value])
|
||||
}}
|
||||
className="ml-auto rounded-md border border-white/12 bg-buehne px-2 py-1 font-mono text-[11px] text-dunst"
|
||||
>
|
||||
<option value="">+ Sprache …</option>
|
||||
{frei.map((s) => (
|
||||
<option key={s.code} value={s.code}>
|
||||
{s.name} ({s.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user