import { useEffect, useState, type ReactNode } from 'react' import { AlertTriangle, Download, Loader2 } from 'lucide-react' import type { MediaItem } from '../api' import { bytes } from '../format' import { faerben, type Sprache } from './Code' import { Markdown } from './Markdown' /** Lesbare Dateien im Betrachter zeigen: Markdown, Code, CSV, Klartext. * * Bis hierhin landete eine `.md` im Zweig „lässt sich hier nicht anzeigen" — * ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen * will. * * ES WIRD NICHT ALLES GEHOLT. Die Größe steht in der Datenbank, also wissen * wir vorher, worauf wir uns einlassen. Über der Grenze wird per * Bereichsanfrage nur der Anfang geladen und das auch gesagt — ein Betrachter, * der bei einem 400-MB-Protokoll den Tab abschießt, ist schlimmer als einer, * der die Datei gar nicht erst öffnet. */ /** 512 KB. Das sind rund 10 000 Zeilen — mehr liest ohnehin niemand am Stück, * und der Herunterladen-Knopf steht daneben. */ const HOECHSTENS = 512 * 1024 type Darstellung = 'markdown' | 'tabelle' | 'code' | 'klartext' const endung = (pfad: string) => pfad.split('.').pop()?.toLowerCase() ?? '' /** Ob wir diese Datei überhaupt anzeigen können. * * ENTSCHIEDEN WIRD NACH DER ENDUNG und erst danach nach der gemeldeten Art. * Der Grund steht in der Datenbank: alles, was vor der erweiterten * Typtabelle hochgeladen wurde, trägt dort `application/octet-stream`. Wer * nur auf die Art schaut, zeigt eine alte README nicht an und eine neue schon * — und sucht den Unterschied an der falschen Stelle. */ export function istLesbar(item: MediaItem): boolean { return ( LESBAR.has(endung(item.path)) || item.mime.startsWith('text/') || item.mime === 'application/json' ) } const LESBAR = new Set([ 'md', 'markdown', 'txt', 'log', 'csv', 'json', 'lua', 'xml', 'yml', 'yaml', 'toml', 'ini', 'cfg', 'sql', 'js', 'ts', 'css', 'html', 'sh', 'ps1', ]) const SPRACHE: Record = { lua: 'lua', json: 'json', sh: 'bash', ps1: 'bash', } function darstellungFuer(pfad: string): Darstellung { const e = endung(pfad) if (e === 'md' || e === 'markdown') return 'markdown' if (e === 'csv') return 'tabelle' if (e in SPRACHE) return 'code' return 'klartext' } export function Textansicht({ item }: { item: MediaItem }) { const [text, setText] = useState(null) const [fehler, setFehler] = useState('') const [gekappt, setGekappt] = useState(false) useEffect(() => { let lebt = true setText(null) setFehler('') setGekappt(false) const zuGross = item.size > HOECHSTENS fetch(item.url, zuGross ? { headers: { Range: `bytes=0-${HOECHSTENS - 1}` } } : undefined) .then(async (r) => { // Ein Dienst, der Bereichsanfragen NICHT beherrscht, antwortet // mit 200 und der ganzen Datei. Unserer kann es -- aber die // Prüfung kostet nichts und der Fehlschlag wäre still. if (!r.ok && r.status !== 206) throw new Error(`HTTP ${r.status}`) const roh = await r.text() if (!lebt) return setGekappt(zuGross) setText(zuGross ? roh.slice(0, HOECHSTENS) : roh) }) .catch((err) => lebt && setFehler(err instanceof Error ? err.message : 'unbekannt')) return () => { lebt = false } }, [item.url, item.size]) if (fehler) { return (
Die Datei ließ sich nicht laden: {fehler}
Herunterladen
) } if (text === null) { return (
) } const art = darstellungFuer(item.path) return ( {gekappt && (

Nur die ersten {bytes(HOECHSTENS)} von {bytes(item.size)} — der Rest steht im Herunterladen.

)} {art === 'markdown' ? ( ) : art === 'tabelle' ? ( ) : ( )}
) } function Rahmen({ children }: { children: ReactNode }) { return (
e.stopPropagation()} > {children}
) } /** Klartext und Code mit Zeilennummern. * * Die Nummern liegen in einer eigenen Spalte statt am Zeilenanfang: so nimmt * ein Markieren mit der Maus sie NICHT mit, und ein kopierter Ausschnitt lässt * sich ohne Nacharbeit einfügen. */ function Zeilen({ text, sprache }: { text: string; sprache?: Sprache }) { const zeilen = text.split('\n') return (
                {zeilen.map((_, i) => i + 1).join('\n')}
            
                {sprache ? faerben(text, sprache) : text}
            
) } /** CSV als Tabelle. * * Mit Anführungszeichen und doppelten Anführungszeichen darin — das ist der * Teil, den eine Zerlegung mit `split(';')` falsch macht, und CSV aus Excel * hat ihn regelmäßig. Trennzeichen wird aus der Kopfzeile geraten: wo mehr * Semikolons als Kommata stehen, ist es ein Semikolon (deutsches Excel). */ function Tabelle({ text }: { text: string }) { const zeilen = text.replace(/\r\n?/g, '\n').split('\n').filter((z) => z.length > 0) const kopf = zeilen[0] ?? '' const trenner = (kopf.match(/;/g)?.length ?? 0) > (kopf.match(/,/g)?.length ?? 0) ? ';' : ',' const zerlegt = zeilen.slice(0, 2000).map((z) => csvZeile(z, trenner)) const breite = Math.max(...zerlegt.map((z) => z.length), 1) return (
{Array.from({ length: breite }, (_, i) => ( ))} {zerlegt.slice(1).map((z, k) => ( {Array.from({ length: breite }, (_, i) => ( ))} ))}
{zerlegt[0]?.[i] ?? ''}
{z[i] ?? ''}
{zeilen.length > 2000 && (

Es werden 2000 von {zeilen.length} Zeilen gezeigt.

)}
) } function csvZeile(zeile: string, trenner: string): string[] { const raus: string[] = [] let feld = '' let inAnfuehrung = false for (let i = 0; i < zeile.length; i++) { const z = zeile[i] if (inAnfuehrung) { if (z === '"') { // Zwei Anführungszeichen sind eines im Feld. if (zeile[i + 1] === '"') { feld += '"' i++ } else inAnfuehrung = false } else feld += z } else if (z === '"') inAnfuehrung = true else if (z === trenner) { raus.push(feld) feld = '' } else feld += z } raus.push(feld) return raus }