Files
rippy/rippy-windows/src/fenster/uebersicht/LaufwerkKachel.tsx
T
HitonabiandClaude Opus 5 0fc87aa022 feat(v5): 4K-UHD nach der Leistung — Grafikkarte mit 4K-Preset komprimiert, nur CPU bleibt verlustfrei
WAS: presetEmpfehlung liefert für UHD das 4K-Preset der gemessenen
Grafikkarten-Familie (VCN/NVENC/QSV) — sonst PRESET_KEINE (verlustfrei);
komprimierenFuer folgt der Empfehlung, wenn der Typ auf „automatisch"
steht. Eigene Wahl schlägt beides. Fenster: Kachel, Kompressions-Karte und
Erst-Einrichtung sagen „(8 Bit)" bzw. „verlustfrei — nur CPU-Encoder".
Gemessen am 12.09.2026 (HandBrake 1.11.2, RX 9070 XT, Akira-UHD-Abbild):
die 4K-Hardware-Presets sind 8-Bit (vce_h265, Profil main); der 10-Bit-
HEVC-Encoder vce_h265_10bit stürzt beim Start ab (Segfault, mit Profil
auto und main10); AV1 10-Bit („AV1 VCN 2160p 4K", vce_av1_10bit) läuft
— steht als eigene Wahl in der Liste, mit dem Hinweis auf AV1-fähige
Abspielgeräte.
WARUM: Durchsicht 12.09.2026, A4 — Commander-Entscheid: „Rippy analysiert
doch die Performance des PCs, könnten wir das nicht davon abhängig
machen?" Vorher rechnete der CPU-Default 4K auf 1080p klein, tagelang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 21:48:38 +02:00

479 lines
24 KiB
TypeScript

// Die Bühne: Backdrop als Panorama, Poster mit Goldkante, Plakat-Titel —
// und beim Arbeiten die Aussteuerungsanzeige samt Mono-Protokoll.
// 5.4.0: Kino-Banner v2 (Logo, Tagline, Meta-Zeile, Darsteller), Laufwerks-
// Gesundheit, Stand der Kompression nach dem Rip. 5.5.0: Automatik-Countdown
// und das Rettungs-Abbild — nur, wenn Rippy Lesefehler gesehen hat.
import { useState } from 'react'
import type {
AutomatikStand,
DiscInfoStand,
GeraeteInfo,
KompressionStand,
LaufwerkBericht,
RipStatus,
VorgangEintrag,
} from '../../gemeinsam/nachrichten'
import { DEFAULT_HB_PRESET, PRESET_KEINE, empfehlungText, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
import {
Badge,
IconDisc,
IconEject,
IconPlay,
IconStift,
IconStop,
InfoKachel,
LAUFENDE_PHASEN,
PHASEN_MONO,
TYP_NAMEN,
backdropUrl,
datumKurz,
discHerkunft,
gb,
logoUrl,
metaZeile,
posterUrlGross,
profilUrl,
restzeitText,
sicherheitsWort,
} from '../bausteine'
import {
automatikJetzt,
automatikStopp,
auswerfen,
laufwerkPruefen,
rettungStarten,
ripAbbrechen,
ripUeberspringen,
type Verbindung,
} from '../kernverbindung'
import { leisteAusRip } from '../../gemeinsam/vorgangsleiste'
import { VorgangsLeiste } from './VorgangsLeiste'
/** Welches Preset diese Disc bekäme — dieselbe Kette wie im Kern
* (eigene Wahl → Empfehlung → Default). */
export function presetAnzeige(typ: GeraeteInfo['typ'], verbindung: Verbindung): string {
if (typ === 'cd') return 'FLAC (verlustfrei)'
if (typ === 'unknown') return 'ISO-Abbild'
const werte = verbindung.einstellungen
if (werte['transcodeEnabled'] === 'false') return 'ohne Kompression'
const schluessel = typ === 'dvd' ? 'transcodePresetDvd' : typ === 'uhd' ? 'transcodePresetUhd' : 'transcodePresetBluray'
const eigene = werte[schluessel] ?? ''
if (eigene === PRESET_KEINE) return 'ohne Kompression'
if (eigene.length > 0) return eigene
const empfehlung = presetEmpfehlung(typ, verbindung.werkzeuge?.backends ?? [], verbindung.werkzeuge?.presets ?? [])
if (empfehlung === PRESET_KEINE) return typ === 'uhd' ? 'verlustfrei (nur CPU-Encoder)' : 'ohne Kompression'
return empfehlungText(typ, empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET)
}
export function LaufwerkKachel({
geraet,
rip,
info,
ripStartMs,
protokoll,
verbindung,
bericht,
vorgang,
kompression,
wartePlatz,
automatik,
aufRippen,
aufAendern,
}: {
geraet: GeraeteInfo
rip: RipStatus | undefined
info: DiscInfoStand | undefined
ripStartMs: number | undefined
protokoll: string[]
verbindung: Verbindung
/** Was das Windows-Protokoll zu diesem Laufwerk sagt (5.4.0). */
bericht: LaufwerkBericht | undefined
/** Der Vorgang des letzten Rips — für den Stand der Kompression (5.4.0). */
vorgang: VorgangEintrag | undefined
kompression: KompressionStand | null
wartePlatz: number
/** Der Automatik-Countdown (5.5.0). */
automatik: AutomatikStand | undefined
/** Die Dialoge leben im App-Zustand (5.4.0): Ein leerer Takt der Wache
* räumte sonst Kachel UND Dialog ab. */
aufRippen: () => void
aufAendern: () => void
}) {
const [protokollOffen, setProtokollOffen] = useState(false)
const [berichtOffen, setBerichtOffen] = useState(false)
const ripLaeuft = rip !== undefined && LAUFENDE_PHASEN.includes(rip.phase)
const discDa = geraet.status === 'ready'
const backdrop = discDa && info !== undefined ? backdropUrl(info.backdropPfad) : ''
const poster = discDa && info !== undefined ? posterUrlGross(info.posterPfad) : ''
const erkannt = discDa && info !== undefined && info.titel.length > 0
const logo = erkannt ? logoUrl(info.logoPfad) : ''
const rettungMoeglich = discDa && !ripLaeuft && rip !== undefined && rip.rettungMoeglich === true
const berichtTon =
bericht === undefined
? ''
: bericht.urteil === 'ruhig'
? 'text-gruen border-gruen/40 bg-gruen/10'
: bericht.urteil === 'disc'
? 'text-warn border-warn/45 bg-warn/10'
: bericht.urteil === 'hardware'
? 'text-rot border-rot/45 bg-rot/10'
: 'text-dunst border-tinte/12 bg-samt/60'
return (
<div className="relative overflow-hidden rounded-2xl border border-tinte/10 bg-karte/50">
{backdrop.length > 0 && (
<>
<img src={backdrop} alt="" className="absolute inset-0 h-full w-full object-cover" />
<div className="absolute inset-0 bg-gradient-to-r from-buehne via-buehne/85 to-buehne/45" />
<div className="absolute inset-0 bg-gradient-to-t from-buehne via-transparent to-transparent" />
</>
)}
<div className="relative p-5">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-2.5 font-mono text-[10.5px] tracking-[0.12em] text-nebel">
<span
className={`inline-block h-2 w-2 rounded-full ${
ripLaeuft
? 'animate-pulse bg-rec shadow-[0_0_9px_oklch(0.72_0.17_45/0.9)]'
: discDa
? 'bg-gruen'
: geraet.status === 'empty'
? 'bg-tinte/25'
: 'bg-rot'
}`}
/>
<span className="text-dunst">LAUFWERK {geraet.id}:</span>
<span>·</span>
<span className="truncate">{geraet.modell.length > 0 ? geraet.modell : 'MODELL UNBEKANNT'}</span>
</div>
<div className="flex flex-none gap-2">
{ripLaeuft ? (
<button
onClick={() => ripAbbrechen(geraet.id)}
className="inline-flex items-center gap-2 rounded-xl border border-rot/60 bg-rot/10 px-4 py-1.5 text-xs font-semibold text-rot hover:bg-rot/20"
>
<IconStop />
Abbrechen
</button>
) : (
<button
onClick={aufRippen}
disabled={!discDa}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-b from-gold-hell to-gold-tief px-5 py-1.5 text-xs font-bold text-buehne shadow-[inset_0_1px_0_oklch(1_0_0/0.4),0_8px_24px_oklch(0.75_0.13_60/0.3)] hover:from-gold-hell hover:to-gold disabled:cursor-not-allowed disabled:opacity-35 disabled:shadow-none"
title="Titel der Disc lesen und auswählen — danach verlustfrei rippen"
>
<IconPlay />
Rippen
</button>
)}
<button
onClick={() => auswerfen(geraet.id)}
disabled={!discDa || ripLaeuft}
className="inline-flex items-center gap-2 rounded-xl border border-tinte/15 bg-gradient-to-b from-karte/80 to-samt/80 px-4 py-1.5 text-xs text-dunst shadow-[inset_0_1px_0_oklch(1_0_0/0.07)] hover:text-schnee disabled:cursor-not-allowed disabled:opacity-35"
title="Entriegeln, auswerfen, nachsehen — in dieser Reihenfolge"
>
<IconEject />
Auswerfen
</button>
</div>
</div>
{!discDa && (
<div className="mt-5 flex items-center gap-5 py-5">
<IconDisc groesse={52} farbe={geraet.status === 'empty' ? 'oklch(0.4 0.02 75)' : 'oklch(0.68 0.19 25)'} />
<p className={`max-w-2xl font-mono text-xs leading-5 ${geraet.status === 'empty' ? 'text-nebel' : 'text-rot'}`}>
{geraet.status === 'empty' ? 'Keine Disc eingelegt — Disc rein genügt, Rippy meldet sich.' : geraet.grund}
</p>
</div>
)}
{discDa && (
<div className="mt-4 flex gap-5">
{poster.length > 0 ? (
<img src={poster} alt="" className="h-44 w-[118px] flex-none rounded-xl object-cover shadow-2xl shadow-black/60 ring-1 ring-gold/35" />
) : (
<div className="flex h-44 w-[118px] flex-none items-center justify-center rounded-xl bg-gradient-to-br from-karte to-samt ring-1 ring-tinte/10">
<IconDisc groesse={40} farbe="oklch(0.5 0.05 75)" />
</div>
)}
<div className="min-w-0 flex-1">
{/* Kino-Banner v2 (5.4.0): Logo statt Text, wo TMDb eines hat —
der größte optische Sprung. Titel und Jahr bleiben lesbar. */}
{erkannt ? (
logo.length > 0 ? (
<div className="flex flex-wrap items-end gap-x-3 gap-y-1">
<img src={logo} alt={info.titel} className="max-h-16 max-w-[380px] object-contain object-left drop-shadow-[0_2px_12px_rgba(0,0,0,0.75)]" />
<span className="pb-0.5 text-sm text-dunst">
{info.titel}
{info.jahr !== null && ` (${info.jahr})`}
</span>
</div>
) : (
<h3 className="truncate font-display text-[26px] font-bold leading-tight text-schnee drop-shadow">
{info.titel}
{info.jahr !== null && <span className="ml-2 font-medium text-dunst">({info.jahr})</span>}
</h3>
)
) : (
<h3 className="font-display text-[26px] font-bold leading-tight text-schnee">{TYP_NAMEN[geraet.typ]}</h3>
)}
{erkannt && info.tagline.length > 0 && <p className="mt-1 font-display text-[15px] italic text-gold-hell/90">{info.tagline}</p>}
{erkannt && metaZeile(info).length > 0 && <p className="mt-1 font-mono text-[11px] tracking-wide text-dunst">{metaZeile(info)}</p>}
{erkannt && info.beschreibung.length > 0 && <p className="mt-1.5 line-clamp-2 max-w-2xl text-xs leading-5 text-dunst">{info.beschreibung}</p>}
{!erkannt && (
<p className="mt-1.5 font-mono text-xs text-nebel">
{geraet.typ === 'cd' ? 'Titel kommen beim Rippen von MusicBrainz.' : 'Noch keine Zuordnung — „Ändern …" hilft nach.'}
</p>
)}
{/* Zwei Kacheln nebeneinander im schmalen Fenster, vier im
breiten — vorher immer vier, also im schmalen gequetscht. */}
<div className="mt-3 grid max-w-3xl grid-cols-2 gap-2.5 sm:grid-cols-4">
<InfoKachel label="MEDIUM" wert={TYP_NAMEN[geraet.typ]} />
<InfoKachel label="GRÖSSE" wert={geraet.groesseBytes > 0 ? gb(geraet.groesseBytes) : '—'} />
<InfoKachel
label="ERKENNUNG"
wert={erkannt ? `${Math.round(info.confidence * 100)} %${info.quelle.length > 0 ? ` · ${info.quelle}` : ''}` : '—'}
farbe={erkannt ? (info.confidence >= 0.8 ? 'text-gruen' : 'text-warn') : undefined}
/>
<InfoKachel label="PRESET" wert={presetAnzeige(geraet.typ, verbindung)} />
</div>
{/* Darsteller-Streifen (5.4.0) */}
{erkannt && info.besetzung.length > 0 && (
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-2">
{info.besetzung.map((d) => (
<div key={`${d.name}-${d.rolle}`} className="flex items-center gap-2" title={d.rolle}>
{d.bildPfad.length > 0 ? (
<img src={profilUrl(d.bildPfad)} alt="" className="h-9 w-9 rounded-full object-cover ring-1 ring-gold/30" />
) : (
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-samt font-mono text-[11px] text-nebel ring-1 ring-tinte/10">
{d.name
.split(' ')
.map((w) => w[0] ?? '')
.join('')
.slice(0, 2)
.toUpperCase()}
</span>
)}
<span className="leading-tight">
<span className="block text-[11px] text-schnee">{d.name}</span>
{d.rolle.length > 0 && <span className="block text-[10px] text-nebel">{d.rolle}</span>}
</span>
</div>
))}
</div>
)}
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
onClick={aufAendern}
disabled={ripLaeuft}
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee disabled:opacity-35"
title="Falsch erkannt? Selbst suchen und wählen — die Ablage folgt deiner Wahl."
>
<IconStift />
Ändern
</button>
<button
onClick={() => {
setBerichtOffen(true)
laufwerkPruefen(geraet.id)
}}
className="inline-flex items-center gap-2 rounded-lg border border-tinte/15 bg-samt/60 px-3 py-1 text-xs text-dunst hover:text-schnee"
title="Liest die cdrom-Ereignisse aus dem Windows-Protokoll: Disc oder Hardware?"
>
Laufwerk prüfen
</button>
{rettungMoeglich && (
<button
onClick={() => rettungStarten(geraet.id)}
className="inline-flex items-center gap-2 rounded-lg border border-warn/50 bg-warn/12 px-3 py-1 text-xs font-semibold text-warn hover:bg-warn/20"
title="Rippy hat an dieser Disc Lesefehler gesehen: erst ein Abbild Sektor für Sektor (Unlesbares mit Nullen), dann derselbe Rip aus dem Abbild — es kann nicht mehr hängen. Braucht doppelten Platz."
>
Rettungs-Abbild
</button>
)}
{erkannt && <Badge text={`${sicherheitsWort(info.confidence)}`} ton={info.confidence >= 0.8 ? 'gruen' : 'gelb'} />}
{info !== undefined && discHerkunft(info).length > 0 && <Badge text={discHerkunft(info)} ton="blau" />}
{info !== undefined && info.schonGerippt !== null && <Badge text={`schon gerippt am ${datumKurz(info.schonGerippt).slice(0, 10)}`} ton="gelb" />}
</div>
{/* Was Rippy NACH dem Info-Lauf dazugelernt hat (Erkennung v2). */}
{erkannt && info.hinweise.length > 0 && (
<div className="mt-2 space-y-0.5">
{info.hinweise.map((h) => (
<p
key={h}
className={`font-mono text-[11px] ${h.includes('NICHT') ? 'text-warn' : h.includes('bestätigt') || h.includes('passt') ? 'text-gruen' : 'text-nebel'}`}
>
{h.includes('NICHT') ? '⚠ ' : h.includes('bestätigt') ? '✓ ' : '· '}
{h}
</p>
))}
</div>
)}
{geraet.grund.length > 0 && <p className="mt-2 font-mono text-xs text-warn">{geraet.grund}</p>}
</div>
</div>
)}
{/* Automatik (5.5.0): Countdown mit Stopp und Jetzt — oder der
Grund, warum Rippy wartet. */}
{discDa && !ripLaeuft && automatik !== undefined && automatik.text.length > 0 && (
<div className={`mt-4 flex flex-wrap items-center gap-3 rounded-xl border p-3 ${automatik.aktiv ? 'border-gold/45 bg-gold/10' : 'border-tinte/10 bg-samt/60'}`}>
{automatik.aktiv && (
<span className="font-mono text-[28px] font-bold leading-none text-gold-hell">
{automatik.sekunden}
<span className="text-sm text-gold/70"> s</span>
</span>
)}
<p className={`min-w-0 flex-1 font-mono text-[11px] leading-5 ${automatik.aktiv ? 'text-gold-hell' : 'text-nebel'}`}>{automatik.text}</p>
{automatik.aktiv && (
<div className="flex gap-2">
<button onClick={() => automatikJetzt(geraet.id)} className="rounded-lg border border-gold/40 bg-gold/15 px-3 py-1 text-[11px] font-semibold text-gold-hell hover:bg-gold/25">
Jetzt
</button>
<button onClick={() => automatikStopp(geraet.id)} className="rounded-lg border border-tinte/15 px-3 py-1 text-[11px] text-dunst hover:text-schnee">
Stopp
</button>
</div>
)}
</div>
)}
{/* Laufwerks-Gesundheit (5.4.0, Punkt 8): Disc oder Hardware? Kommt
von selbst nach einem gescheiterten Rip — oder auf Knopfdruck. */}
{bericht !== undefined && (berichtOffen || bericht.urteil !== 'ruhig') && (
<div className={`mt-4 rounded-xl border p-3 ${berichtTon}`}>
<div className="flex items-start justify-between gap-3">
<p className="font-mono text-[11px] leading-5">
<span className="font-semibold">
{bericht.urteil === 'ruhig' ? 'LAUFWERK RUHIG' : bericht.urteil === 'disc' ? 'VERDACHT: DIE DISC' : bericht.urteil === 'hardware' ? 'VERDACHT: DIE HARDWARE' : 'PROTOKOLL'}
</span>{' '}
{bericht.text}
</p>
<button onClick={() => setBerichtOffen(false)} className="flex-none font-mono text-[10.5px] text-nebel hover:text-dunst">
ausblenden
</button>
</div>
{bericht.letzte.length > 0 && (
<div className="mt-1.5 space-y-0.5">
{bericht.letzte.map((z, i) => (
<p key={i} className="font-mono text-[10.5px] text-nebel">
{z}
</p>
))}
</div>
)}
</div>
)}
{rip !== undefined && (
<div className="mt-4 rounded-xl border border-tinte/10 bg-samt/80 p-4 backdrop-blur">
<div className="flex items-end justify-between gap-5">
<div className="min-w-0 flex-1">
<div className="mb-2 flex items-baseline justify-between gap-3">
<span
className={`font-mono text-[10.5px] tracking-[0.12em] ${
rip.phase === 'fehler' ? 'text-rot' : rip.phase === 'fertig' ? (rip.lesefehler ? 'text-warn' : 'text-gruen') : 'text-gold-hell'
}`}
>
{PHASEN_MONO[rip.phase]}
{rip.lesefehler && rip.phase === 'fertig' ? ' — MIT LESEFEHLERN' : ''}
</span>
{ripLaeuft && <span className="font-mono text-[10.5px] text-nebel">{restzeitText(rip.prozent, ripStartMs)}</span>}
</div>
{/* 5.6.0: die Vorgangs-Leiste — der ganze Weg, hier mit den
Details der Laufwerks-Stationen; nach dem Rip nur der Stand. */}
<VorgangsLeiste stand={leisteAusRip(rip, vorgang, wartePlatz)} />
{ripLaeuft && (rip.titelGesamt ?? 0) > 1 && (
<p className="mt-1.5 font-mono text-[10.5px] text-nebel">
Titel {rip.titelNr} von {rip.titelGesamt}
</p>
)}
{rip.rettung !== undefined && (
<p className="mt-1.5 font-mono text-[10.5px] text-warn">
Rettungs-Abbild: {rip.rettung.unlesbareSektoren} unlesbare Sektoren an {rip.rettung.stellen} Stellen mit Nullen gefüllt
</p>
)}
{/* Der Defekt-Waechter (§ 6.2): Dieselbe Stelle wieder und
wieder ist kein Fortschritt, sondern ein physischer
Defekt. Vorher lief MakeMKV dort stundenlang weiter,
waehrend identische Zeilen durchs Protokoll rauschten. */}
{rip.haengt != null && (
<div className="mt-3 rounded-xl border border-warn/45 bg-warn/10 p-3">
<p className="font-mono text-[11px] font-semibold text-warn">Die Disc hat an einer Stelle einen Defekt.</p>
<p className="mt-1 font-mono text-[11px] leading-5 text-dunst">
MakeMKV versucht es seit {rip.haengt.wiederholungen} Meldungen immer wieder an derselben Stelle (Byte {rip.haengt.offset}). Das wird nicht
besser der Rest der Disc ist davon nicht betroffen. Nach dem Abbruch bietet Rippy das Rettungs-Abbild an.
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<button
onClick={() => ripUeberspringen(geraet.id)}
className="rounded-lg border border-warn/50 bg-warn/15 px-3 py-1 text-[11px] font-semibold text-warn hover:bg-warn/25"
title="Diesen Titel aufgeben und mit dem nächsten weitermachen"
>
Titel überspringen
</button>
<span className="font-mono text-[10.5px] text-nebel">oder weiter versuchen abbrechen geht oben.</span>
</div>
</div>
)}
<p className="mt-2 truncate font-mono text-[11px] text-nebel" title={rip.text}>
&gt; {rip.text}
</p>
{rip.dateien.map((datei) => (
<p key={datei} className="break-all font-mono text-[11px] text-nebel/80">
{datei}
</p>
))}
{/* Nach dem Rip (5.6.0, Commander: „Nur in der Kompressions-Karte"):
hier nur noch EINE Zeile, wo es weitergeht — Balken, Prozent,
Restzeit und Ziel stehen in der Kompressions-Karte, danach im
Verlauf. Fehler bleiben sichtbar (R4). */}
{rip.phase === 'fertig' && vorgang !== undefined && (
<p className={`mt-2 font-mono text-[11px] ${vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen' ? 'text-warn' : 'text-nebel'}`}>
{kompression !== null
? 'Kompression läuft — Stand in der Kompressions-Karte unten.'
: wartePlatz > 0
? `Kompression wartet — Platz ${wartePlatz} in der Warteschlange (Karte unten).`
: vorgang.phase === 'fertig'
? 'Kompression fertig — das Ergebnis steht im Verlauf.'
: vorgang.phase === 'fehler' || vorgang.phase === 'abgebrochen'
? `⚠ ${vorgang.fehler.length > 0 ? vorgang.fehler : `Kompression ${vorgang.phase}`}`
: `Kompression: ${vorgang.phase}`}
</p>
)}
</div>
{ripLaeuft && rip.prozent >= 0 && (
<div className="flex-none font-mono text-[42px] font-bold leading-none text-gold-hell">
{rip.prozent}
<span className="text-lg text-gold/70">%</span>
</div>
)}
</div>
{protokoll.length > 1 && (
<button onClick={() => setProtokollOffen((o) => !o)} className="mt-2 font-mono text-[10.5px] text-nebel hover:text-dunst">
{protokollOffen ? '▾ PROTOKOLL AUSBLENDEN' : `▸ PROTOKOLL (${protokoll.length} ZEILEN)`}
</button>
)}
{protokollOffen && (
<div className="mt-1 max-h-40 space-y-0.5 overflow-y-auto rounded-lg bg-buehne p-2">
{protokoll.map((zeile, i) => (
<p key={i} className="break-all font-mono text-[10.5px] leading-4 text-nebel">
&gt; {zeile}
</p>
))}
</div>
)}
</div>
)}
</div>
</div>
)
}