Bild, Video, Ton und PDF konnte der Betrachter schon. Was fehlte, war
ausgerechnet die Art, die man am ehesten nur lesen und nicht herunterladen
will: eine .md landete im Zweig "laesst sich hier nicht anzeigen".
LESBARES. Markdown gesetzt, .lua und .json eingefaerbt, CSV als Tabelle, alles
Uebrige mit Zeilennummern. Der Tokenizer ist DERSELBE wie fuer die Schnipsel
auf der API-Seite und nicht ein zweiter -- zwei waeren zwei, die auseinander
laufen. Die Zeilennummern liegen in einer eigenen Spalte: so nimmt ein
Markieren mit der Maus sie nicht mit.
Die CSV-Zerlegung kann Anfuehrungszeichen und doppelte darin. Das ist der Teil,
den ein split(';') falsch macht, und CSV aus Excel hat ihn regelmaessig; das
Trennzeichen wird aus der Kopfzeile geraten.
NICHT ALLES WIRD GEHOLT. Die Groesse steht in der Datenbank, also wissen wir
vorher, worauf wir uns einlassen: ueber 512 KB kommt per Bereichsanfrage nur
der Anfang, und das steht auch da. Ein Betrachter, der bei einem 400-MB-
Protokoll den Tab abschiesst, ist schlimmer als einer, der die Datei gar nicht
erst oeffnet. Gemessen: Range: bytes=0-49 -> 206, 50 Bytes.
MARKDOWN OHNE BIBLIOTHEK, und das ist kein Geiz. Der uebliche Weg heisst marked
plus DOMPurify und endet bei dangerouslySetInnerHTML -- damit haengt die
Sicherheit dieser Seite an der Frage, ob die Filterliste vollstaendig ist. Hier
entsteht NIE eine HTML-Zeichenkette: der Text wird zu React-Knoten, und ein
<script> in einer hochgeladenen .md erscheint als die acht Zeichen, die es ist.
Im ganzen ui/ steht kein einziges dangerouslySetInnerHTML.
Die eine Luecke, die React nicht schliesst, ist href: [klick](javascript:...)
kaeme durch. Zwoelf Faelle in Node durchgeprueft, alle bestanden -- http,
https, mailto und relative Ziele durch; javascript:, JavaScript:, mit
Leerzeichen davor, data:, vbscript:, file: abgelehnt und als Text stehen
gelassen.
DER PLAYER. Die Bedienelemente bleiben die des Browsers: ein selbstgebauter
Schieber sieht in jedem Browser anders falsch aus, kennt keine
Tastatursteuerung und keine Untertitel. Drumherum kam, was der Browser nicht
mitbringt.
Lautstaerke ueber Dateien hinweg -- <video> setzt sie bei jedem neuen Element
auf 1 zurueck, und wer sich durch zwanzig Clips klickt, stellt sie sonst
zwanzigmal leise, waehrend der einundzwanzigste ungefragt wieder hochfaehrt.
Weiter zum naechsten Stueck, abschaltbar, und nur wenn das Naechste auch etwas
zum Abspielen ist: nach einem Lied ungefragt bei einem Bild zu landen waere
schlechter als stehenzubleiben.
Tastatur an EINER Stelle: Leertaste haelt an, Pfeile springen fuenf Sekunden,
Umschalt+Pfeil wechselt die Datei. Bei allem anderen blaettern die Pfeile wie
bisher. Dass das so ist, steht als Zeile unter dem Bild -- eine unsichtbare
Sonderregel fuehlt sich an wie ein Fehler.
SERVER. Die Typtabelle kennt jetzt md, markdown, csv, lua, xml, yml, yaml,
toml, ini, cfg, sql und log -- alle als text/plain. Nicht als text/markdown
(braechte nichts) und erst recht nicht als text/html: wer einen Upload-Token
hat, koennte damit eine Seite unter unserem Namen veroeffentlichen. html, css
und js stehen deshalb weiterhin NICHT drin.
Der Betrachter entscheidet trotzdem nach der Endung und erst danach nach der
gemeldeten Art. Grund steht in der Datenbank: items/readme.md lag schon da und
trug application/octet-stream, weil sie vor der erweiterten Tabelle hochgeladen
wurde. Wer nur auf die Art schaut, zeigt die alte README nicht an und eine neue
schon -- und sucht den Unterschied an der falschen Stelle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
246 lines
9.0 KiB
TypeScript
246 lines
9.0 KiB
TypeScript
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<string, Sprache> = {
|
|
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<string | null>(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 (
|
|
<Rahmen>
|
|
<div className="flex items-center gap-2 text-sm text-schlecht">
|
|
<AlertTriangle size={16} />
|
|
Die Datei ließ sich nicht laden: {fehler}
|
|
</div>
|
|
<a
|
|
href={item.url}
|
|
download
|
|
className="mt-3 inline-flex items-center gap-2 rounded-md border border-rand px-3 py-1.5 text-sm hover:bg-flaeche-hoch"
|
|
>
|
|
<Download size={15} /> Herunterladen
|
|
</a>
|
|
</Rahmen>
|
|
)
|
|
}
|
|
|
|
if (text === null) {
|
|
return (
|
|
<Rahmen>
|
|
<div className="flex justify-center py-8">
|
|
<Loader2 className="animate-spin text-schrift-leise" />
|
|
</div>
|
|
</Rahmen>
|
|
)
|
|
}
|
|
|
|
const art = darstellungFuer(item.path)
|
|
|
|
return (
|
|
<Rahmen>
|
|
{gekappt && (
|
|
<p className="mb-3 flex items-center gap-2 rounded border border-warn/40 bg-warn/10 px-3 py-1.5 text-xs text-warn">
|
|
<AlertTriangle size={14} className="shrink-0" />
|
|
Nur die ersten {bytes(HOECHSTENS)} von {bytes(item.size)} — der Rest steht im
|
|
Herunterladen.
|
|
</p>
|
|
)}
|
|
|
|
{art === 'markdown' ? (
|
|
<Markdown text={text} />
|
|
) : art === 'tabelle' ? (
|
|
<Tabelle text={text} />
|
|
) : (
|
|
<Zeilen text={text} sprache={SPRACHE[endung(item.path)]} />
|
|
)}
|
|
</Rahmen>
|
|
)
|
|
}
|
|
|
|
function Rahmen({ children }: { children: ReactNode }) {
|
|
return (
|
|
<div
|
|
className="max-h-[calc(100vh-9rem)] w-full max-w-4xl overflow-auto rounded-lg border border-white/15 bg-flaeche p-5 text-left"
|
|
// Ohne das rollt der Klick zum Hintergrund durch und schließt den
|
|
// Betrachter mitten im Lesen.
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="flex gap-3 font-mono text-xs leading-relaxed">
|
|
<pre className="shrink-0 text-right text-schrift-leise/40 select-none">
|
|
{zeilen.map((_, i) => i + 1).join('\n')}
|
|
</pre>
|
|
<pre className="min-w-0 flex-1 whitespace-pre-wrap break-words">
|
|
<code>{sprache ? faerben(text, sprache) : text}</code>
|
|
</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/** 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 (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full border-collapse text-xs">
|
|
<thead>
|
|
<tr className="border-b border-rand text-left">
|
|
{Array.from({ length: breite }, (_, i) => (
|
|
<th key={i} className="px-2 py-1 font-medium whitespace-nowrap">
|
|
{zerlegt[0]?.[i] ?? ''}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{zerlegt.slice(1).map((z, k) => (
|
|
<tr key={k} className="border-b border-rand/40">
|
|
{Array.from({ length: breite }, (_, i) => (
|
|
<td key={i} className="px-2 py-1 font-mono">
|
|
{z[i] ?? ''}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
{zeilen.length > 2000 && (
|
|
<p className="mt-2 text-xs text-schrift-leise">
|
|
Es werden 2000 von {zeilen.length} Zeilen gezeigt.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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
|
|
}
|