Files
Laufzettel/src/components/laeufe/schritt-anhaenge.tsx
T
florian.wilde 3ccf3aa2d9
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(anhaenge): Fotos und Dateien an einem Schritt (#52) (#59)
2026-10-05 20:49:26 +00:00

171 lines
6.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}