This commit was merged in pull request #59.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user