35cfcbcb07
Ampel / ampel (push) Successful in 27s
Commander-Rueckmeldung: Umlaute fehlen. Ausloeser war sichtbar der Installer -
"Diese Maschine uebernimmt die Video-Kompression fuer Rippy" stand woertlich im
Screenshot.
## Die .ps1-Falle war loesbar, nicht unumgehbar
Bisher galt: ausgelieferte .ps1 MUESSEN ASCII sein, weil PowerShell 5.1 sie
ohne BOM als ANSI liest. Das ist nur die halbe Wahrheit - gemessen mit echtem
powershell.exe 5.1:
ohne BOM: $s = "Größe: äöü" + Unerwartetes Token -> Skript kaputt
mit BOM: Groesse: aeoeue + laeuft, Length 16 korrekt
Alle drei Skripte sind jetzt UTF-8 MIT BOM und tragen echte Umlaute; unter 5.1
gegengeprueft (BOM vorhanden, Parser fehlerfrei, Text korrekt gelesen). Der
Kopfkommentar sagt das jetzt richtig statt "ASCII-only".
## Umstellung: Text ja, Bezeichner nein
Umlaute gehoeren in Kommentare und Anzeigetexte, nicht in Funktionsnamen oder
Datenschluessel. Deshalb je Sprache das passende Werkzeug:
- Python: ueber den TOKENIZER - angefasst wurden ausschliesslich COMMENT- und
STRING-Tokens. 156 Stellen. Code ist damit garantiert unberuehrt.
- TypeScript: nur // und /* */ Kommentare sowie JSX-Text (kann per Definition
kein Bezeichner sein). 24 Stellen. `const waehlen`, `let laeuft`, `plaetze`,
`GeraetInfo` sind nachweislich unversehrt.
- install.sh: Anzeigetext, aber die Shell-Funktionen (gruen/rot/gelb/titel) und
der Schalter --nur-pruefen bleiben ASCII - das sind Schnittstellen.
- Markdown: 0 Aenderungen, die Doku hatte schon Umlaute.
ZWEI FEHLER MEINES KONVERTERS, beide von Werkzeugen gefangen:
1. In f-Strings steht in {...} CODE, kein Text. Aus f"{groesse}" wurde
f"{größe}", waehrend die Variable groesse hiess - Ruff meldete F821
"Undefined name". Der Konverter lagert Einsetzungen jetzt aus.
2. Ein Dict-Schluessel wurde umbenannt: die Wortliste enthaelt das PRAEFIX
"uebersprung", der Tabu-Schutz prueft aber ganze Woerter. bericht[...] ist
wieder ASCII - Umlaute in Datenschluesseln brechen JSON-Runden und DB-Felder.
## Installer im Rippy-Look
Statt hellgrau jetzt dieselben Toene wie das Web-UI (aus lib/design.ts
uebernommen): slate-900 Flaeche, dunkle Eingabefelder, Amber-Hauptknopf wie
"Los geht's" im Wizard. Oben ein Kopfbereich mit dem Farbverlauf
amber -> indigo -> purple und dem Disc-Symbol - in WinForms per Paint-Ereignis
gezeichnet, weil es dort keine Verlaeufe von der Stange gibt.
Geprueft, nicht gehofft: Das Fenster wurde headless in ein PNG gerendert
(DrawToBitmap) und angesehen - Verlauf, Symbol, Umlaute und Farben sitzen.
RippyWorkerSetup.exe neu gebaut (57344 -> 60416 Bytes); Umlaute und das
requireAdministrator-Manifest sind in der .exe verifiziert.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
414 lines
18 KiB
TypeScript
414 lines
18 KiB
TypeScript
import { useState, useEffect } from 'react'
|
||
import { Disc, Cpu, Globe, Tv, CheckCircle, ArrowRight, HardDrive, AlertTriangle } from 'lucide-react'
|
||
import { api } from '../lib/api'
|
||
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
|
||
|
||
/** Was beim Abschliessen gespeichert wird — abhängig davon, was die Maschine kann. */
|
||
function presetsFuer(schwach: boolean) {
|
||
if (schwach) {
|
||
// Gemessen auf einer CPU ohne AVX2: 4K-HEVC = 28-55 h pro Film. Also 4K
|
||
// verlustfrei behalten und für den Rest H.264 nehmen (deutlich schneller
|
||
// als H.265, dafür etwas größer).
|
||
return {
|
||
transcodePreset: 'HQ 1080p30 Surround',
|
||
transcodePresetBluray: 'HQ 1080p30 Surround',
|
||
transcodePresetDvd: 'HQ 576p25 Surround',
|
||
transcodePresetUhd: PRESET_KEINE,
|
||
}
|
||
}
|
||
return {
|
||
transcodePreset: 'H.265 MKV 1080p30',
|
||
transcodePresetBluray: 'H.265 MKV 1080p30',
|
||
transcodePresetDvd: 'H.265 MKV 576p25',
|
||
transcodePresetUhd: 'H.265 MKV 2160p60 4K',
|
||
}
|
||
}
|
||
|
||
export default function FirstRunWizard({ onDone }: { onDone: () => void }) {
|
||
const [tmdbKey, setTmdbKey] = useState('')
|
||
const [omdbKey, setOmdbKey] = useState('')
|
||
const [mediaServer, setMediaServer] = useState('jellyfin')
|
||
const [transcodeEnabled, setTranscodeEnabled] = useState(true)
|
||
const [workers, setWorkers] = useState<WorkerInfo[]>([])
|
||
const [geraete, setGeraete] = useState<GeraetInfo[]>([])
|
||
const [plaetze, setPlaetze] = useState<PlatzInfo[]>([])
|
||
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] = await Promise.allSettled([
|
||
api.get('/capabilities'),
|
||
api.get('/devices'),
|
||
api.get('/system/info'),
|
||
])
|
||
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 || [])
|
||
setGeladen(true)
|
||
}
|
||
laden()
|
||
const takt = setInterval(laden, 5000)
|
||
return () => { laeuft = false; clearInterval(takt) }
|
||
}, [])
|
||
|
||
const schwach = schwacheEncoderCpu(workers)
|
||
const presets = presetsFuer(schwach)
|
||
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() } : {}),
|
||
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
|
||
titel="Encoding-Worker"
|
||
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="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."
|
||
/>
|
||
<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>
|
||
|
||
{/* ---- 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">
|
||
Keiner deiner Worker kann 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>
|
||
</>
|
||
) : (
|
||
<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}
|
||
</li>
|
||
<li><strong>Blu-ray:</strong> {presets.transcodePresetBluray}</li>
|
||
<li><strong>DVD:</strong> {presets.transcodePresetDvd}</li>
|
||
</ul>
|
||
<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>
|
||
)
|
||
}
|