171 lines
6.4 KiB
TypeScript
171 lines
6.4 KiB
TypeScript
"use client";
|
||
|
||
import { useActionState, useRef, useState } from "react";
|
||
import { Camera, FileText, Paperclip, X } from "lucide-react";
|
||
import { ERLAUBTE_MEDIENTYPEN, groesseText, istBild, maxGroesseBytes } from "@/lib/anhaenge";
|
||
import {
|
||
anhangEntfernenAktion,
|
||
anhangHochladenAktion,
|
||
type AktionsErgebnis,
|
||
} from "@/server/actions/anhaenge";
|
||
|
||
/**
|
||
* Anhaenge eines Schritts (#032).
|
||
*
|
||
* Zwei Wege zum Anhaengen, weil zwei Geraete gemeint sind: **Foto** oeffnet am
|
||
* Handy direkt die Kamera (`capture="environment"`), **Datei** den Dateiwaehler.
|
||
* Am Rechner tun beide dasselbe — dort gibt es keine Kamera-App, und der Browser
|
||
* zeigt in beiden Faellen den Waehler.
|
||
*
|
||
* Abgeschickt wird sofort nach der Wahl, ohne zweiten Knopf: ein Foto, das man
|
||
* gerade gemacht hat, will man nicht noch bestaetigen. Ein eigener
|
||
* „Hochladen“-Knopf waere der Schritt, den man vergisst.
|
||
*/
|
||
|
||
export type SchrittAnhang = {
|
||
readonly id: string;
|
||
readonly dateiname: string;
|
||
readonly medientyp: string;
|
||
readonly groesse: number;
|
||
readonly erstelltVon: string | null;
|
||
};
|
||
|
||
export function SchrittAnhaenge({
|
||
laufId,
|
||
vorlagenSchrittId,
|
||
anhaenge,
|
||
lesend,
|
||
}: {
|
||
laufId: string;
|
||
vorlagenSchrittId: string;
|
||
anhaenge: readonly SchrittAnhang[];
|
||
lesend: boolean;
|
||
}) {
|
||
const [hochladen, hochladenAktion, laedt] = useActionState<AktionsErgebnis, FormData>(
|
||
anhangHochladenAktion,
|
||
{},
|
||
);
|
||
const [entfernen, entfernenAktion] = useActionState<AktionsErgebnis, FormData>(anhangEntfernenAktion, {});
|
||
const formular = useRef<HTMLFormElement>(null);
|
||
const [zuGross, setzeZuGross] = useState<string | null>(null);
|
||
|
||
function gewaehlt(ereignis: React.ChangeEvent<HTMLInputElement>): void {
|
||
const datei = ereignis.target.files?.[0];
|
||
if (!datei) return;
|
||
/*
|
||
* Die Groesse schon hier pruefen. Sonst wandern bei einem Fehlgriff 200 MB
|
||
* durch die Leitung, nur damit der Server sie ablehnt — am Handy im
|
||
* Mobilfunk ist das der Unterschied zwischen einer Meldung und einer
|
||
* Minute Warten. Dienst und Action pruefen es trotzdem noch einmal.
|
||
*/
|
||
if (datei.size > maxGroesseBytes()) {
|
||
setzeZuGross(
|
||
`„${datei.name}“ ist ${groesseText(datei.size)} groß. Erlaubt sind höchstens ` +
|
||
`${groesseText(maxGroesseBytes())}.`,
|
||
);
|
||
ereignis.target.value = "";
|
||
return;
|
||
}
|
||
setzeZuGross(null);
|
||
formular.current?.requestSubmit();
|
||
}
|
||
|
||
const meldung = zuGross ?? hochladen.fehler ?? entfernen.fehler;
|
||
|
||
return (
|
||
<div className="flex flex-col gap-2">
|
||
{anhaenge.length > 0 ? (
|
||
<ul className="flex flex-wrap gap-2">
|
||
{anhaenge.map((anhang) => (
|
||
<li key={anhang.id} className="flex flex-col gap-1">
|
||
<a
|
||
href={`/api/anhaenge/${anhang.id}`}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
title={`${anhang.dateiname} · ${groesseText(anhang.groesse)}${
|
||
anhang.erstelltVon ? ` · ${anhang.erstelltVon}` : ""
|
||
}`}
|
||
className="flex items-center gap-2 rounded-karte border border-linie bg-flaeche-hoch p-1.5"
|
||
>
|
||
{istBild(anhang.medientyp) ? (
|
||
// Das Bild selbst als Miniatur – ohne next/image, weil der
|
||
// Optimierer die Datei erneut und ohne Rechteprüfung holen
|
||
// würde.
|
||
// eslint-disable-next-line @next/next/no-img-element
|
||
<img
|
||
src={`/api/anhaenge/${anhang.id}`}
|
||
alt={anhang.dateiname}
|
||
className="size-14 rounded object-cover"
|
||
/>
|
||
) : (
|
||
<span className="flex size-14 items-center justify-center rounded bg-flaeche">
|
||
<FileText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
|
||
</span>
|
||
)}
|
||
<span className="flex min-w-0 max-w-40 flex-col text-xs">
|
||
<span className="truncate">{anhang.dateiname}</span>
|
||
<span className="text-nebentext">{groesseText(anhang.groesse)}</span>
|
||
</span>
|
||
</a>
|
||
{lesend ? null : (
|
||
<form action={entfernenAktion}>
|
||
<input type="hidden" name="anhangId" value={anhang.id} />
|
||
<input type="hidden" name="laufId" value={laufId} />
|
||
<button
|
||
type="submit"
|
||
aria-label={`${anhang.dateiname} entfernen`}
|
||
className="flex min-h-11 items-center gap-1 text-xs text-nebentext hover:text-fehler md:min-h-8"
|
||
>
|
||
<X className="size-3.5" strokeWidth={2} aria-hidden="true" />
|
||
entfernen
|
||
</button>
|
||
</form>
|
||
)}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
|
||
{lesend ? null : (
|
||
<form ref={formular} action={hochladenAktion} className="flex flex-wrap items-center gap-2">
|
||
<input type="hidden" name="laufId" value={laufId} />
|
||
<input type="hidden" name="vorlagenSchrittId" value={vorlagenSchrittId} />
|
||
|
||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||
<Camera className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||
Foto
|
||
<input
|
||
type="file"
|
||
name="datei"
|
||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||
capture="environment"
|
||
onChange={gewaehlt}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
|
||
<label className="flex min-h-11 cursor-pointer items-center gap-1.5 rounded-pille border border-linie px-3 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-9">
|
||
<Paperclip className="size-4" strokeWidth={1.75} aria-hidden="true" />
|
||
Datei
|
||
<input
|
||
type="file"
|
||
name="datei"
|
||
accept={ERLAUBTE_MEDIENTYPEN.join(",")}
|
||
onChange={gewaehlt}
|
||
className="sr-only"
|
||
/>
|
||
</label>
|
||
|
||
{laedt ? <span className="text-sm text-nebentext">wird hochgeladen …</span> : null}
|
||
</form>
|
||
)}
|
||
|
||
{meldung ? (
|
||
<p role="alert" className="max-w-prose text-sm text-fehler">
|
||
{meldung}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|