feat: Betrachter fuer Lesbares, und aus dem <video> wird ein Player
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>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useRef, type ReactNode } from 'react'
|
||||
import {
|
||||
Check,
|
||||
ChevronLeft,
|
||||
@@ -7,26 +7,31 @@ import {
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileQuestion,
|
||||
Repeat,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import type { MediaItem } from '../api'
|
||||
import { useStore } from '../store'
|
||||
import { bytes, zeit } from '../format'
|
||||
import { useMerker } from '../merker'
|
||||
import { useKopieren } from './Bausteine'
|
||||
import { Player } from './Player'
|
||||
import { istLesbar, Textansicht } from './Textansicht'
|
||||
|
||||
/** Eine Datei ansehen, ohne die Seite zu verlassen.
|
||||
*
|
||||
* Bilder, Videos, Ton und PDF werden HIER gezeigt; alles andere bekommt einen
|
||||
* ehrlichen Hinweis samt Herunterladen-Knopf. Ein Betrachter, der bei einer
|
||||
* .zip ein leeres schwarzes Feld zeigt, sieht aus wie ein kaputter
|
||||
* Betrachter.
|
||||
* Bilder, Videos, Ton, PDF und alles Lesbare (Markdown, Code, CSV, Klartext)
|
||||
* werden HIER gezeigt; alles andere bekommt einen ehrlichen Hinweis samt
|
||||
* Herunterladen-Knopf. Ein Betrachter, der bei einer .zip ein leeres
|
||||
* schwarzes Feld zeigt, sieht aus wie ein kaputter Betrachter.
|
||||
*
|
||||
* Gezeigt wird IMMER die Vollfassung und nie die Vorschau: 320 Pixel breit
|
||||
* reichen für eine Kachel, aber nicht dafür, ein Bild zu beurteilen — und
|
||||
* genau dazu klickt man darauf. */
|
||||
export function Ansehen({
|
||||
item,
|
||||
naechstes,
|
||||
hatVorher,
|
||||
hatNachher,
|
||||
onVorher,
|
||||
@@ -35,6 +40,9 @@ export function Ansehen({
|
||||
onLoeschen,
|
||||
}: {
|
||||
item: MediaItem
|
||||
/** Was danach kaeme — gebraucht fuer "weiter zum naechsten Stueck": nur
|
||||
* wenn das auch etwas Abspielbares ist, wird weitergesprungen. */
|
||||
naechstes?: MediaItem
|
||||
hatVorher: boolean
|
||||
hatNachher: boolean
|
||||
onVorher: () => void
|
||||
@@ -44,20 +52,56 @@ export function Ansehen({
|
||||
}) {
|
||||
const note = useStore((s) => s.note)
|
||||
const { kopiert, kopieren } = useKopieren()
|
||||
const [weiter, setWeiter] = useMerker('player.weiter', true)
|
||||
|
||||
// Tastatur: Escape zu, Pfeile weiter. Wer 900 Bilder durchsieht, will
|
||||
// nicht 900 Mal zielen.
|
||||
const art = item.mime.split('/')[0]
|
||||
const spielt = art === 'video' || art === 'audio'
|
||||
const element = useRef<HTMLMediaElement | null>(null)
|
||||
|
||||
// Tastatur: EINE Stelle fuer alles. Escape zu, Pfeile weiter — wer 900
|
||||
// Bilder durchsieht, will nicht 900 Mal zielen.
|
||||
//
|
||||
// BEI VIDEO UND TON SPRINGEN DIE PFEILE ABER IN DER DATEI, nicht in der
|
||||
// Liste. Das ist der Griff, den jeder aus jedem Player kennt, und "in der
|
||||
// Aufnahme fuenf Sekunden zurueck" ist dort haeufiger als "eine Datei
|
||||
// zurueck". Zum Blaettern kommt die Umschalttaste dazu — und die Pfeile am
|
||||
// Bildrand tun es ohnehin, die sieht man.
|
||||
//
|
||||
// Die Leertaste haelt an und laesst weiterlaufen. Sie steht HIER und nicht
|
||||
// im Player: sonst rollt der Browser damit die Seite darunter.
|
||||
useEffect(() => {
|
||||
const taste = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onSchliessen()
|
||||
else if (e.key === 'ArrowLeft' && hatVorher) onVorher()
|
||||
if (e.key === 'Escape') {
|
||||
onSchliessen()
|
||||
return
|
||||
}
|
||||
|
||||
const el = element.current
|
||||
if (spielt && el) {
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault()
|
||||
if (el.paused) void el.play()
|
||||
else el.pause()
|
||||
return
|
||||
}
|
||||
if (!e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight')) {
|
||||
e.preventDefault()
|
||||
const ziel = el.currentTime + (e.key === 'ArrowLeft' ? -5 : 5)
|
||||
// Number.isFinite: solange nichts geladen ist, ist duration
|
||||
// NaN — und eine Zuweisung von NaN an currentTime wirft.
|
||||
el.currentTime = Number.isFinite(el.duration)
|
||||
? Math.min(Math.max(0, ziel), el.duration)
|
||||
: Math.max(0, ziel)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowLeft' && hatVorher) onVorher()
|
||||
else if (e.key === 'ArrowRight' && hatNachher) onNachher()
|
||||
}
|
||||
window.addEventListener('keydown', taste)
|
||||
return () => window.removeEventListener('keydown', taste)
|
||||
}, [hatVorher, hatNachher, onVorher, onNachher, onSchliessen])
|
||||
|
||||
const art = item.mime.split('/')[0]
|
||||
}, [hatVorher, hatNachher, onVorher, onNachher, onSchliessen, spielt])
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -97,6 +141,28 @@ export function Ansehen({
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Nur bei Abspielbarem — ein Schalter ohne Wirkung ist ein
|
||||
Schalter, an dem jemand herumdrueckt und sich fragt, warum
|
||||
nichts passiert. */}
|
||||
{spielt && (
|
||||
<button
|
||||
onClick={() => setWeiter((w) => !w)}
|
||||
title={
|
||||
weiter
|
||||
? 'Nach dem Ende weiter zum nächsten Stück'
|
||||
: 'Am Ende stehen bleiben'
|
||||
}
|
||||
className={`flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-xs ${
|
||||
weiter
|
||||
? 'border-akzent/50 bg-akzent/15 text-akzent'
|
||||
: 'border-white/15 text-schrift-leise hover:bg-white/10 hover:text-schrift'
|
||||
}`}
|
||||
>
|
||||
<Repeat size={13} />
|
||||
{weiter ? 'weiter' : 'einzeln'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<a
|
||||
href={item.url}
|
||||
target="_blank"
|
||||
@@ -128,39 +194,45 @@ export function Ansehen({
|
||||
</header>
|
||||
|
||||
{/* Der Inhalt */}
|
||||
<div className="relative flex min-h-0 flex-1 items-center justify-center p-4">
|
||||
<div className="relative flex min-h-0 flex-1 flex-col items-center justify-center gap-3 p-4">
|
||||
{hatVorher && (
|
||||
<Pfeil links onClick={onVorher} />
|
||||
)}
|
||||
{hatNachher && <Pfeil onClick={onNachher} />}
|
||||
|
||||
<div
|
||||
className="flex max-h-full max-w-full items-center justify-center"
|
||||
className="flex min-h-0 max-h-full w-full max-w-full items-center justify-center"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{art === 'image' ? (
|
||||
<img
|
||||
src={item.url}
|
||||
alt={item.path}
|
||||
className="max-h-[calc(100vh-8rem)] max-w-full object-contain"
|
||||
className="max-h-[calc(100vh-9rem)] max-w-full object-contain"
|
||||
/>
|
||||
) : art === 'video' ? (
|
||||
<video
|
||||
src={item.url}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-h-[calc(100vh-8rem)] max-w-full"
|
||||
) : spielt ? (
|
||||
<Player
|
||||
item={item}
|
||||
elementRef={element}
|
||||
onEnde={() => {
|
||||
// Nur weiter, wenn das Naechste auch etwas zum
|
||||
// Abspielen ist. Sonst landet man nach einem
|
||||
// Lied ungefragt bei einem Bild.
|
||||
if (!weiter || !hatNachher) return
|
||||
const naechsteArt = naechstes?.mime.split('/')[0]
|
||||
if (naechsteArt === 'video' || naechsteArt === 'audio') {
|
||||
onNachher()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : art === 'audio' ? (
|
||||
<div className="w-full max-w-lg rounded-lg border border-white/15 bg-flaeche p-6">
|
||||
<audio src={item.url} controls className="w-full" />
|
||||
</div>
|
||||
) : item.mime === 'application/pdf' ? (
|
||||
<iframe
|
||||
src={item.url}
|
||||
title={item.path}
|
||||
className="h-[calc(100vh-8rem)] w-full max-w-4xl rounded border border-white/15 bg-white"
|
||||
className="h-[calc(100vh-9rem)] w-full max-w-4xl rounded border border-white/15 bg-white"
|
||||
/>
|
||||
) : istLesbar(item) ? (
|
||||
<Textansicht item={item} />
|
||||
) : (
|
||||
// Der ehrliche Fall: wir können es nicht zeigen, und
|
||||
// sagen das, statt ein leeres Feld hinzustellen.
|
||||
@@ -183,11 +255,40 @@ export function Ansehen({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Die Tastenbelegung DAZUSCHREIBEN. Dass die Pfeile bei einem
|
||||
Video springen statt zu blättern, ist die richtige Wahl —
|
||||
aber sie ist unsichtbar, und eine unsichtbare Sonderregel
|
||||
fühlt sich an wie ein Fehler. Eine Zeile Text kostet nichts. */}
|
||||
<p
|
||||
className="shrink-0 text-center text-[11px] text-schrift-leise/60"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{spielt ? (
|
||||
<>
|
||||
<Taste>Leer</Taste> Wiedergabe · <Taste>←</Taste>
|
||||
<Taste>→</Taste> 5 s springen · <Taste>Umschalt</Taste>+
|
||||
<Taste>←</Taste>
|
||||
<Taste>→</Taste> Datei wechseln · <Taste>Esc</Taste> schließen
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Taste>←</Taste>
|
||||
<Taste>→</Taste> blättern · <Taste>Esc</Taste> schließen
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const Taste = ({ children }: { children: ReactNode }) => (
|
||||
<kbd className="mx-0.5 rounded border border-white/15 px-1 py-0.5 font-mono text-[10px]">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
|
||||
function Pfeil({ links, onClick }: { links?: boolean; onClick: () => void }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useStore } from '../store'
|
||||
* seine Grenzen — verschachtelte Sonderfälle färbt er falsch. Bei einem
|
||||
* Beispielblock von zehn Zeilen ist das der richtige Tausch. */
|
||||
|
||||
type Sprache = 'lua' | 'bash' | 'json'
|
||||
export type Sprache = 'lua' | 'bash' | 'json'
|
||||
|
||||
const MUSTER: Record<Sprache, RegExp> = {
|
||||
// Reihenfolge zählt: Kommentare zuerst, sonst frisst eine Zeichenkette
|
||||
@@ -25,7 +25,10 @@ const MUSTER: Record<Sprache, RegExp> = {
|
||||
|
||||
const FARBEN = ['text-schrift-leise/70 italic', 'text-gut', 'text-akzent', 'text-warn']
|
||||
|
||||
function faerben(text: string, sprache: Sprache) {
|
||||
/** Wird auch von der Textansicht benutzt: EIN Tokenizer fuer den Schnipsel auf
|
||||
* der API-Seite und fuer die geoeffnete .lua im Betrachter. Zwei waeren zwei,
|
||||
* die auseinanderlaufen. */
|
||||
export function faerben(text: string, sprache: Sprache) {
|
||||
const muster = new RegExp(MUSTER[sprache].source, 'g')
|
||||
const stuecke: React.ReactNode[] = []
|
||||
let letzte = 0
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** Markdown darstellen — ohne Bibliothek, und das ist hier kein Geiz.
|
||||
*
|
||||
* Der übliche Weg heißt `marked` plus `DOMPurify`: der erste macht aus
|
||||
* Markdown eine HTML-Zeichenkette, der zweite versucht, das Gefährliche wieder
|
||||
* herauszuschneiden, und am Ende steht ein `dangerouslySetInnerHTML`. Damit
|
||||
* hängt die Sicherheit dieser Seite an der Frage, ob die Filterliste
|
||||
* vollständig ist.
|
||||
*
|
||||
* HIER ENTSTEHT NIE EINE HTML-ZEICHENKETTE. Der Text wird zu React-Knoten, und
|
||||
* React setzt alles als Text ein. Ein `<script>` in einer hochgeladenen
|
||||
* `.md` erscheint als die acht Zeichen, die es ist. Es gibt kein
|
||||
* `dangerouslySetInnerHTML` in dieser Datei, und das ist der ganze Punkt —
|
||||
* die Dateien hier kommen von außen, jeder mit einem Upload-Token kann eine
|
||||
* ablegen.
|
||||
*
|
||||
* Was er kann: Überschriften, Absätze, fett, kursiv, durchgestrichen, Code
|
||||
* am Stück und im Fluss, Listen (auch nummeriert), Zitate, Tabellen, Linien,
|
||||
* Links, Bilder.
|
||||
*
|
||||
* Was er NICHT kann, damit niemand danach sucht: verschachtelte Listen,
|
||||
* Fußnoten, Aufgabenlisten, eingebettetes HTML (siehe oben), Zeilenumbruch
|
||||
* durch zwei Leerzeichen. Das ist die Grenze, und für eine README neben ein
|
||||
* paar Bildern reicht sie. */
|
||||
export function Markdown({ text }: { text: string }) {
|
||||
return <div className="flex flex-col gap-3">{bloecke(text)}</div>
|
||||
}
|
||||
|
||||
// -- Blockebene ---------------------------------------------------------------
|
||||
|
||||
function bloecke(text: string): ReactNode[] {
|
||||
const zeilen = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
const raus: ReactNode[] = []
|
||||
let i = 0
|
||||
|
||||
const schluessel = () => `b${i}`
|
||||
|
||||
while (i < zeilen.length) {
|
||||
const zeile = zeilen[i] ?? ''
|
||||
|
||||
// Leerzeile: nichts zu tun, sie trennt bereits.
|
||||
if (!zeile.trim()) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Code am Stück. Ein nicht geschlossener Block läuft bis zum Ende --
|
||||
// besser als der Rest der Datei in Kursiv.
|
||||
const zaun = /^```(\w*)\s*$/.exec(zeile)
|
||||
if (zaun) {
|
||||
const start = i + 1
|
||||
let ende = start
|
||||
while (ende < zeilen.length && !/^```\s*$/.test(zeilen[ende] ?? '')) ende++
|
||||
raus.push(
|
||||
<pre
|
||||
key={schluessel()}
|
||||
className="overflow-x-auto rounded-md border border-rand bg-grund p-3 font-mono text-xs leading-relaxed"
|
||||
>
|
||||
<code>{zeilen.slice(start, ende).join('\n')}</code>
|
||||
</pre>,
|
||||
)
|
||||
i = ende + 1
|
||||
continue
|
||||
}
|
||||
|
||||
// Linie. Vor der Liste geprüft, sonst frisst "***" die Kursivschrift.
|
||||
if (/^ {0,3}([-*_])(\s*\1){2,}\s*$/.test(zeile)) {
|
||||
raus.push(<hr key={schluessel()} className="border-rand" />)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
const ueber = /^(#{1,6})\s+(.*)$/.exec(zeile)
|
||||
if (ueber) {
|
||||
const stufe = ueber[1]!.length
|
||||
const groesse = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-sm'][
|
||||
stufe - 1
|
||||
]
|
||||
raus.push(
|
||||
<p
|
||||
key={schluessel()}
|
||||
className={`${groesse} font-semibold text-schrift ${stufe <= 2 ? 'mt-2 border-b border-rand pb-1' : ''}`}
|
||||
>
|
||||
{inline(ueber[2] ?? '')}
|
||||
</p>,
|
||||
)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
// Tabelle: Kopf, Trennzeile aus Strichen, dann Zeilen.
|
||||
if (zeile.includes('|') && /^\s*\|?[\s:|-]+\|[\s:|-]*$/.test(zeilen[i + 1] ?? '')) {
|
||||
const kopf = spalten(zeile)
|
||||
const zeilenRaus: string[][] = []
|
||||
let j = i + 2
|
||||
while (j < zeilen.length && (zeilen[j] ?? '').includes('|')) {
|
||||
zeilenRaus.push(spalten(zeilen[j] ?? ''))
|
||||
j++
|
||||
}
|
||||
raus.push(
|
||||
<div key={schluessel()} className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-rand text-left">
|
||||
{kopf.map((z, k) => (
|
||||
<th key={k} className="px-3 py-1.5 font-medium">
|
||||
{inline(z)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{zeilenRaus.map((r, k) => (
|
||||
<tr key={k} className="border-b border-rand/50">
|
||||
{r.map((z, l) => (
|
||||
<td key={l} className="px-3 py-1.5">
|
||||
{inline(z)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>,
|
||||
)
|
||||
i = j
|
||||
continue
|
||||
}
|
||||
|
||||
if (/^ {0,3}>\s?/.test(zeile)) {
|
||||
const teile: string[] = []
|
||||
while (i < zeilen.length && /^ {0,3}>\s?/.test(zeilen[i] ?? '')) {
|
||||
teile.push((zeilen[i] ?? '').replace(/^ {0,3}>\s?/, ''))
|
||||
i++
|
||||
}
|
||||
raus.push(
|
||||
<blockquote
|
||||
key={schluessel()}
|
||||
className="border-l-2 border-akzent/50 pl-3 text-schrift-leise"
|
||||
>
|
||||
{inline(teile.join(' '))}
|
||||
</blockquote>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
const punkte = /^ {0,3}([-*+]|\d+\.)\s+/.exec(zeile)
|
||||
if (punkte) {
|
||||
const nummeriert = /\d/.test(punkte[1] ?? '')
|
||||
const eintraege: string[] = []
|
||||
while (i < zeilen.length) {
|
||||
const m = /^ {0,3}([-*+]|\d+\.)\s+(.*)$/.exec(zeilen[i] ?? '')
|
||||
if (!m) break
|
||||
if (/\d/.test(m[1] ?? '') !== nummeriert) break
|
||||
eintraege.push(m[2] ?? '')
|
||||
i++
|
||||
}
|
||||
const Liste = nummeriert ? 'ol' : 'ul'
|
||||
raus.push(
|
||||
<Liste
|
||||
key={schluessel()}
|
||||
className={`ml-5 flex list-outside flex-col gap-1 text-sm ${
|
||||
nummeriert ? 'list-decimal' : 'list-disc'
|
||||
}`}
|
||||
>
|
||||
{eintraege.map((e, k) => (
|
||||
<li key={k}>{inline(e)}</li>
|
||||
))}
|
||||
</Liste>,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// Absatz: alles bis zur nächsten Leerzeile.
|
||||
const teile: string[] = []
|
||||
while (i < zeilen.length && (zeilen[i] ?? '').trim() && !istBlockanfang(zeilen[i] ?? '')) {
|
||||
teile.push(zeilen[i] ?? '')
|
||||
i++
|
||||
}
|
||||
// Kein Fortschritt heißt Endlosschleife — lieber die Zeile roh
|
||||
// ausgeben als den Browser einfrieren.
|
||||
if (teile.length === 0) {
|
||||
teile.push(zeile)
|
||||
i++
|
||||
}
|
||||
raus.push(
|
||||
<p key={schluessel()} className="text-sm leading-relaxed">
|
||||
{inline(teile.join(' '))}
|
||||
</p>,
|
||||
)
|
||||
}
|
||||
|
||||
return raus
|
||||
}
|
||||
|
||||
const istBlockanfang = (z: string) =>
|
||||
/^```/.test(z) || /^#{1,6}\s/.test(z) || /^ {0,3}>/.test(z) || /^ {0,3}([-*+]|\d+\.)\s/.test(z)
|
||||
|
||||
const spalten = (zeile: string) =>
|
||||
zeile
|
||||
.trim()
|
||||
.replace(/^\|/, '')
|
||||
.replace(/\|$/, '')
|
||||
.split('|')
|
||||
.map((z) => z.trim())
|
||||
|
||||
// -- Im Fluss -----------------------------------------------------------------
|
||||
|
||||
/** Nur Adressen, die nichts ausführen.
|
||||
*
|
||||
* `[klick](javascript:…)` ist der eine Weg, auf dem hier trotz React-Knoten
|
||||
* noch Code hereinkäme — href wird von React NICHT geprüft. Erlaubt sind
|
||||
* deshalb http, https, mailto und relative Ziele; alles andere wird zu
|
||||
* gewöhnlichem Text. */
|
||||
function sichereAdresse(roh: string): string | null {
|
||||
const ziel = roh.trim()
|
||||
if (/^(https?:|mailto:)/i.test(ziel)) return ziel
|
||||
if (/^[a-z][a-z0-9+.-]*:/i.test(ziel)) return null
|
||||
return ziel
|
||||
}
|
||||
|
||||
const MUSTER =
|
||||
/(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(~~[^~\n]+~~)|(\*[^*\n]+\*|_[^_\n]+_)|(!?\[[^\]\n]*\]\([^)\s]+\))/g
|
||||
|
||||
function inline(text: string): ReactNode[] {
|
||||
const raus: ReactNode[] = []
|
||||
const muster = new RegExp(MUSTER.source, 'g')
|
||||
let letzte = 0
|
||||
let treffer: RegExpExecArray | null
|
||||
|
||||
while ((treffer = muster.exec(text)) !== null) {
|
||||
if (treffer.index > letzte) raus.push(text.slice(letzte, treffer.index))
|
||||
const roh = treffer[0]
|
||||
const k = treffer.index
|
||||
|
||||
if (treffer[1]) {
|
||||
raus.push(
|
||||
<code key={k} className="rounded bg-grund px-1 py-0.5 font-mono text-[0.85em]">
|
||||
{roh.slice(1, -1)}
|
||||
</code>,
|
||||
)
|
||||
} else if (treffer[2]) {
|
||||
raus.push(
|
||||
<strong key={k} className="font-semibold text-schrift">
|
||||
{roh.slice(2, -2)}
|
||||
</strong>,
|
||||
)
|
||||
} else if (treffer[3]) {
|
||||
raus.push(
|
||||
<s key={k} className="text-schrift-leise">
|
||||
{roh.slice(2, -2)}
|
||||
</s>,
|
||||
)
|
||||
} else if (treffer[4]) {
|
||||
raus.push(<em key={k}>{roh.slice(1, -1)}</em>)
|
||||
} else {
|
||||
const m = /^(!?)\[([^\]]*)\]\(([^)\s]+)\)$/.exec(roh)!
|
||||
const ziel = sichereAdresse(m[3] ?? '')
|
||||
if (!ziel) {
|
||||
// Abgelehnt — als Text stehen lassen, damit sichtbar bleibt,
|
||||
// dass da etwas stand.
|
||||
raus.push(roh)
|
||||
} else if (m[1]) {
|
||||
raus.push(
|
||||
<img
|
||||
key={k}
|
||||
src={ziel}
|
||||
alt={m[2] ?? ''}
|
||||
className="my-2 max-h-96 max-w-full rounded border border-rand"
|
||||
/>,
|
||||
)
|
||||
} else {
|
||||
raus.push(
|
||||
<a
|
||||
key={k}
|
||||
href={ziel}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="text-akzent underline underline-offset-2 hover:text-akzent/80"
|
||||
>
|
||||
{m[2] ?? ziel}
|
||||
</a>,
|
||||
)
|
||||
}
|
||||
}
|
||||
letzte = k + roh.length
|
||||
}
|
||||
|
||||
if (letzte < text.length) raus.push(text.slice(letzte))
|
||||
return raus
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect, type RefObject } from 'react'
|
||||
import { Music } from 'lucide-react'
|
||||
import type { MediaItem } from '../api'
|
||||
import { useMerker } from '../merker'
|
||||
|
||||
/** Video und Ton abspielen.
|
||||
*
|
||||
* Die Bedienelemente sind die des Browsers, und das ist eine Entscheidung:
|
||||
* ein selbstgebauter Schieber sieht in jedem Browser anders falsch aus,
|
||||
* kennt keine Tastatursteuerung und keine Untertitel. Was der Browser NICHT
|
||||
* mitbringt, steht hier drumherum:
|
||||
*
|
||||
* LAUTSTÄRKE ÜBER DATEIEN HINWEG. Das `<video>`-Element setzt sie bei jedem
|
||||
* neuen Element auf 1 zurück. Wer sich durch zwanzig Clips klickt, stellt sie
|
||||
* sonst zwanzigmal leise — und beim einundzwanzigsten fährt sie ungefragt
|
||||
* wieder hoch, wenn jemand danebensteht.
|
||||
*
|
||||
* WEITER ZUM NÄCHSTEN. Ohne das ist es kein Player, sondern eine Vorschau,
|
||||
* die nach jedem Stück stehen bleibt. Abschaltbar, denn beim Durchsehen von
|
||||
* Aufnahmen will man das Gegenteil.
|
||||
*
|
||||
* Beides wird gemerkt. Es sind Ansichtssachen im Sinne von `merker.ts`: sie
|
||||
* formen die Ansicht, sie schränken nichts ein. */
|
||||
export function Player({
|
||||
item,
|
||||
elementRef,
|
||||
onEnde,
|
||||
}: {
|
||||
item: MediaItem
|
||||
elementRef: RefObject<HTMLMediaElement | null>
|
||||
onEnde: () => void
|
||||
}) {
|
||||
const [ton, setTon] = useMerker('player.ton', { laut: 1, still: false })
|
||||
const istVideo = item.mime.startsWith('video')
|
||||
|
||||
// Die gemerkte Lautstärke auf das NEUE Element übertragen. Ohne diesen
|
||||
// Effekt gilt sie nur für das erste, danach fängt jedes bei 1 an.
|
||||
useEffect(() => {
|
||||
const el = elementRef.current
|
||||
if (!el) return
|
||||
el.volume = ton.laut
|
||||
el.muted = ton.still
|
||||
}, [elementRef, item.url, ton.laut, ton.still])
|
||||
|
||||
const merken = () => {
|
||||
const el = elementRef.current
|
||||
if (!el) return
|
||||
// Nur schreiben, wenn sich wirklich etwas geändert hat: sonst löst der
|
||||
// eigene Effekt oben ein volumechange aus, das wieder schreibt.
|
||||
if (el.volume !== ton.laut || el.muted !== ton.still) {
|
||||
setTon({ laut: el.volume, still: el.muted })
|
||||
}
|
||||
}
|
||||
|
||||
if (istVideo) {
|
||||
return (
|
||||
<video
|
||||
// Der Schlüssel zwingt React, beim Wechsel ein NEUES Element zu
|
||||
// bauen. Ohne ihn behält das alte seine Abspielposition, und
|
||||
// der nächste Clip startet mitten im Bild.
|
||||
key={item.url}
|
||||
ref={elementRef as RefObject<HTMLVideoElement>}
|
||||
src={item.url}
|
||||
controls
|
||||
autoPlay
|
||||
onVolumeChange={merken}
|
||||
onEnded={onEnde}
|
||||
className="max-h-[calc(100vh-9rem)] max-w-full rounded"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-lg rounded-lg border border-white/15 bg-flaeche p-6">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-md bg-akzent/15">
|
||||
<Music size={22} className="text-akzent" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
{/* Der Dateiname, gross. Bei Ton gibt es kein Bild, an dem
|
||||
man erkennt, was gerade laeuft. */}
|
||||
<p className="truncate text-sm text-schrift" title={item.path}>
|
||||
{item.path.split('/').pop()}
|
||||
</p>
|
||||
<p className="truncate text-xs text-schrift-leise">
|
||||
{item.path.split('/').slice(0, -1).join('/') || '(Wurzel)'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<audio
|
||||
key={item.url}
|
||||
ref={elementRef as RefObject<HTMLAudioElement>}
|
||||
src={item.url}
|
||||
controls
|
||||
autoPlay
|
||||
onVolumeChange={merken}
|
||||
onEnded={onEnde}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
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
|
||||
}
|
||||
@@ -624,6 +624,7 @@ export function Galerie() {
|
||||
return (
|
||||
<Ansehen
|
||||
item={item}
|
||||
naechstes={items[i + 1]}
|
||||
hatVorher={i > 0}
|
||||
hatNachher={i < items.length - 1}
|
||||
onVorher={() => setAnsehen(items[i - 1]!.id)}
|
||||
|
||||
Reference in New Issue
Block a user