feat: hochladen aus dem Dashboard -- Bilder, Videos, Ton, PDF

POST /api/dash/upload, eigener Weg neben /api/upload. Der dortige haengt an
einem Bearer-Token; wer im Dashboard sitzt, hat eine SITZUNG und keinen Token,
und einen Token nur zum Hochladen anzulegen waere ein Geheimnis mehr, das
jemand verlieren kann.

Mehrere Dateien auf einmal, und JEDE bekommt ihr eigenes Ergebnis mit Pfad,
Groesse und Grund im Fehlerfall. Ein "3 von 5 hochgeladen" ohne die Namen der
zwei anderen ist genau die Sorte Rueckmeldung, wegen der wir hier neu bauen.
Eine kaputte Datei bricht die anderen nicht ab.

parseBody({ all: true }) ist noetig: ohne das liefert Hono bei mehreren Feldern
gleichen Namens nur das LETZTE, und aus fuenf Dateien wuerde eine.

DATEINAMEN WERDEN ZURECHTGEBOGEN, und zwar sichtbar. checkPath laesst
"Mein Video (final) 2.MP4" mit Recht nicht durch, also macht safeFilename
daraus "mein-video-final-2.mp4" -- Umlaute werden umschrieben und nicht
weggeworfen ("Grüße" -> "gruesse", nicht "gr-e"). Die Ergebnisliste zeigt den
neuen Pfad neben dem alten Namen.

BEIM MESSEN GEFUNDEN: bleibt vom Namen nichts uebrig -- "ЖУРНАЛ.txt",
"   .png", "★★★.txt" --, hiessen bisher ALLE "datei.txt", und die zweite
Datei ueberschrieb die erste stillschweigend. Jetzt haengen die ersten acht
Stellen des SHA-256 daran: gleicher Inhalt ergibt denselben Namen,
verschiedener Inhalt verschiedene. Nachgemessen mit drei solchen Namen.

Der Fortschrittsbalken laeuft ueber XMLHttpRequest und nicht fetch: nur der
kennt upload.onprogress, und bei einem 40-MB-Video ist ein Balken der
Unterschied zwischen "es tut sich was" und "ist es abgestuerzt?".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:29:33 +02:00
co-authored by Claude Opus 5
parent f7086c231c
commit 3ddab937e7
5 changed files with 387 additions and 0 deletions
+10
View File
@@ -147,6 +147,16 @@ export type Folders = {
wurzel: { files: number; bytes: number }
}
export type UploadErgebnis = {
name: string
ok: boolean
path?: string
url?: string
size?: number
replaced?: boolean
error?: string
}
export type ThumbLauf = {
erzeugt: number
vorhanden: number
+185
View File
@@ -0,0 +1,185 @@
import { useRef, useState } from 'react'
import { CheckCircle2, Upload, X, XCircle } from 'lucide-react'
import type { UploadErgebnis } from '../api'
import { useStore } from '../store'
import { bytes } from '../format'
import { Knopf } from './Bausteine'
/** Hochladen aus dem Dashboard.
*
* Bilder, Videos, Ton, PDF — was der Dienst kennt, steht in der MIME-Tabelle
* in storage.ts. Alles andere landet als application/octet-stream und wird
* heruntergeladen statt angezeigt; das ist kein Fehler, sondern die sichere
* Voreinstellung.
*
* JEDE Datei bekommt ihre eigene Zeile mit Ergebnis. "3 von 5 hochgeladen"
* ohne die Namen der zwei anderen ist genau die Sorte Rückmeldung, wegen der
* wir hier neu bauen. */
export function Hochladen({ ordner, fertig }: { ordner: string; fertig: () => void }) {
const note = useStore((s) => s.note)
const [ueber, setUeber] = useState(false)
const [laeuft, setLaeuft] = useState(false)
const [fortschritt, setFortschritt] = useState(0)
const [ergebnisse, setErgebnisse] = useState<UploadErgebnis[] | null>(null)
const feld = useRef<HTMLInputElement>(null)
async function schicken(dateien: FileList | File[]) {
const liste = [...dateien]
if (liste.length === 0) return
setLaeuft(true)
setFortschritt(0)
setErgebnisse(null)
const form = new FormData()
for (const d of liste) form.append('file', d)
if (ordner) form.append('folder', ordner)
try {
// XMLHttpRequest und nicht fetch: nur der kennt upload.onprogress.
// Bei einem 40-MB-Video ist ein Balken der Unterschied zwischen
// "es tut sich was" und "ist es abgestürzt?".
const antwort = await new Promise<{ status: number; text: string }>(
(auf, ab) => {
const x = new XMLHttpRequest()
x.open('POST', '/api/dash/upload')
x.withCredentials = true
x.upload.onprogress = (e) => {
if (e.lengthComputable) setFortschritt(e.loaded / e.total)
}
x.onload = () => auf({ status: x.status, text: x.responseText })
x.onerror = () => ab(new Error('Die Verbindung brach ab'))
x.send(form)
},
)
const daten = JSON.parse(antwort.text)
if (antwort.status !== 200) {
throw new Error(daten?.error ?? `HTTP ${antwort.status}`)
}
setErgebnisse(daten.ergebnisse)
if (daten.fehlgeschlagen === 0) {
note('ok', `${daten.hochgeladen} hochgeladen.`)
} else {
note(
'fehler',
`${daten.hochgeladen} hochgeladen, ${daten.fehlgeschlagen} nicht — siehe Liste.`,
)
}
if (daten.hochgeladen > 0) fertig()
} catch (err) {
note('fehler', err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLaeuft(false)
if (feld.current) feld.current.value = ''
}
}
return (
<div className="flex flex-col gap-2">
<div
onDragOver={(e) => {
e.preventDefault()
setUeber(true)
}}
onDragLeave={() => setUeber(false)}
onDrop={(e) => {
e.preventDefault()
setUeber(false)
void schicken(e.dataTransfer.files)
}}
onClick={() => feld.current?.click()}
className={`flex cursor-pointer flex-col items-center gap-1 rounded-lg border
border-dashed px-4 py-5 text-center text-sm transition-colors ${
ueber
? 'border-akzent bg-akzent/10 text-schrift'
: 'border-rand text-schrift-leise hover:border-akzent/50 hover:text-schrift'
}`}
>
<Upload size={20} />
<span>
Dateien hierher ziehen oder <span className="text-akzent">auswählen</span>
</span>
<span className="text-xs">
Ziel:{' '}
<code className="font-mono">{ordner ? `${ordner}/` : '(Wurzel)'}</code> ·
Bilder, Videos, Ton, PDF
</span>
</div>
<input
ref={feld}
type="file"
multiple
hidden
onChange={(e) => e.target.files && void schicken(e.target.files)}
/>
{laeuft && (
<div className="h-1.5 w-full overflow-hidden rounded-full bg-flaeche-hoch">
<div
className="h-1.5 rounded-full bg-akzent transition-[width]"
style={{ width: `${Math.round(fortschritt * 100)}%` }}
/>
</div>
)}
{ergebnisse && (
<div className="rounded-lg border border-rand bg-flaeche">
<div className="flex items-center justify-between border-b border-rand px-3 py-1.5">
<span className="text-xs font-semibold">Ergebnis</span>
<button
onClick={() => setErgebnisse(null)}
className="text-schrift-leise hover:text-schrift"
aria-label="Liste schließen"
>
<X size={14} />
</button>
</div>
<ul className="max-h-52 overflow-auto p-2 text-xs">
{ergebnisse.map((e, i) => (
<li key={i} className="flex items-start gap-2 py-0.5">
{e.ok ? (
<CheckCircle2 size={13} className="mt-0.5 shrink-0 text-gut" />
) : (
<XCircle size={13} className="mt-0.5 shrink-0 text-schlecht" />
)}
<span className="min-w-0 flex-1">
<span className="font-mono">{e.path ?? e.name}</span>
{e.ok ? (
<span className="text-schrift-leise">
{' '}
{bytes(e.size ?? 0)}
{e.replaced && ' (ersetzt)'}
{/* Der zurechtgebogene Name wird
GEZEIGT: wer "Mein Video.MP4"
zieht, soll sehen, dass daraus
"mein-video.mp4" wurde. */}
{e.path && !e.path.endsWith(e.name) && (
<span className="text-schrift-leise/70">
{' '}
· aus {e.name}"
</span>
)}
</span>
) : (
<span className="text-schlecht"> — {e.error}</span>
)}
</span>
</li>
))}
</ul>
</div>
)}
{ergebnisse === null && !laeuft && (
<Knopf
className="self-start lg:hidden"
onClick={() => feld.current?.click()}
kind="Dateien wählen"
/>
)}
</div>
)
}
+9
View File
@@ -17,6 +17,7 @@ import { run, useStore } from '../store'
import { bytes, istBild, zeit } from '../format'
import { Knopf, Leer, Nachfrage, useKopieren } from '../components/Bausteine'
import { Ordner } from '../components/Ordner'
import { Hochladen } from '../components/Hochladen'
const PRO_SEITE = 60
@@ -215,6 +216,14 @@ export function Galerie() {
)}
</div>
<Hochladen
ordner={ordner}
fertig={() => {
void laden()
void ordnerLaden()
}}
/>
{/* Das Raster */}
{items.length === 0 && !laedt ? (
<Leer