From ff365c3cb1f5192cb628499991d1c999afed9178 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Tue, 15 Sep 2026 21:10:12 +0200 Subject: [PATCH] feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Chargenseite (/charge/[code]) zeigt zwei Bildkacheln: links Ernte und Jahr, rechts den Standort. Gefuellt wurden sie aus einer Bilder-LISTE, in der allein die Reihenfolge entschied, welches Bild wohin kommt. Damit liess sich das zweite Bild nicht allein setzen. Wer nur ein Foto vom Standort pflegen wollte, bekam es auf Position 0 — also in die linke Kachel, wo Ernte und Jahr stehen sollten. Welche Kachel ein Bild fuellt, war im Formular ausserdem nirgends erkennbar. Jetzt zwei benannte Slots statt einer Liste, im Chargen- wie im Ernten-Formular: Bild 1 — linke Kachel auf der Chargenseite Bild 2 — rechte Kachel auf der Chargenseite (Standort) Jeder Slot hat eine eigene Vorschau, einen eigenen Auswahl- und einen eigenen Entfernen-Knopf. Beide tragen denselben Feldnamen "images" und werden in dieser Reihenfolge uebermittelt. Dafuer brauchte es einen positionserhaltenden Parser: strArray() trimmt und filtert leere Werte heraus, wodurch ein leerer erster Slot verschwunden und der zweite nach vorn gerutscht waere — genau der Fehler, der behoben werden sollte. Neu ist strArraySlots(), das leere Slots als "" stehen laesst und nur am Ende stehende entfernt (ein leeres Feld ergibt weiterhin [] statt ["", ""]). Die Konsumenten mussten entsprechend von "??" auf "||" wechseln: ein im Admin geleerter Slot kommt als leerer String an, nicht als undefined. Mit "??" haette dieser leere String als gueltiger Wert gegolten und das Erntebild darunter verdeckt (charge/[code], ernten, ernten/[year]/[type]). strArray/strArraySlots sind nach lib/form-fields.ts gewandert und werden aus _shared.ts re-exportiert — saemtliche bestehenden Importe bleiben unveraendert. Grund: _shared.ts zieht ueber lib/prisma.ts einen Datenbank-Adapter nach und liess sich ohne DATABASE_URL nicht testen. Als reine Funktionen ohne Datenbankbezug sind die beiden jetzt direkt pruefbar, passend zur bereits dokumentierten Teststrategie des Projekts. Sieben neue Tests decken den Kernfall ab (leerer erster Slot bleibt offen), mit Gegenprobe, dass strArray denselben Fall weiterhin zusammenrutschen laesst. Produktbilder bleiben bewusst bei der bisherigen Liste: dort ist die Bilderzahl offen und die Reihenfolge traegt keine Kachel-Bedeutung. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/admin/actions/_shared.ts | 12 ++--- src/app/admin/actions/harvests-batches.ts | 25 ++++++++-- src/app/admin/actions/image-slots.test.ts | 61 +++++++++++++++++++++++ src/app/admin/chargen/[id]/page.tsx | 28 ++++++++--- src/app/admin/ernten/[id]/page.tsx | 26 +++++++--- src/app/charge/[code]/page.tsx | 8 ++- src/app/ernten/[year]/[type]/page.tsx | 2 +- src/app/ernten/page.tsx | 2 +- src/i18n/dictionaries/de.ts | 4 ++ src/i18n/dictionaries/en.ts | 4 ++ src/i18n/dictionary-type.ts | 4 ++ src/lib/form-fields.ts | 33 ++++++++++++ 12 files changed, 182 insertions(+), 27 deletions(-) create mode 100644 src/app/admin/actions/image-slots.test.ts create mode 100644 src/lib/form-fields.ts diff --git a/src/app/admin/actions/_shared.ts b/src/app/admin/actions/_shared.ts index 13f20fe..3d4a297 100644 --- a/src/app/admin/actions/_shared.ts +++ b/src/app/admin/actions/_shared.ts @@ -59,9 +59,9 @@ export function strOrNull(formData: FormData, key: string): string | null { * Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben * Namen) als getrimmtes String-Array ohne leere Eintraege. */ -export function strArray(formData: FormData, key: string): string[] { - return formData - .getAll(key) - .map((value) => String(value).trim()) - .filter(Boolean); -} +// Reine FormData-Leser ohne Datenbankbezug liegen in lib/form-fields.ts, +// damit sie sich ohne DATABASE_URL testen lassen (_shared.ts zieht ueber +// lib/prisma.ts einen Datenbank-Adapter nach, sobald es importiert wird). +// Re-Export, damit alle bestehenden Importe aus dieser Datei weiterhin +// funktionieren. +export { strArray, strArraySlots } from "@/lib/form-fields"; diff --git a/src/app/admin/actions/harvests-batches.ts b/src/app/admin/actions/harvests-batches.ts index 69cc23f..aa70a83 100644 --- a/src/app/admin/actions/harvests-batches.ts +++ b/src/app/admin/actions/harvests-batches.ts @@ -5,7 +5,22 @@ import { revalidatePath } from "next/cache"; import { prisma } from "@/lib/prisma"; import { logAdminAction } from "@/lib/audit-log"; import { isValidUploadPath } from "@/lib/uploads"; -import { requireAdmin, fail, str, num, strArray } from "./_shared"; +import { requireAdmin, fail, str, num, strArraySlots } from "./_shared"; + +/** + * Bildslots fuer Ernte und Charge. Die Position entscheidet, WELCHE Kachel + * auf /charge/[code] das Bild fuellt (Slot 1 links, Slot 2 rechts/Standort) — + * deshalb ein leerer Slot als "" statt eines Eintrags weniger. Ein ungueltiger + * Pfad wird ebenfalls zu "" und damit zum Platzhalter, statt die + * nachfolgenden Slots zu verschieben. + */ +function imageSlots(formData: FormData): string[] { + const slots = strArraySlots(formData, "images").map((path) => + isValidUploadPath(path) ? path : "" + ); + while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop(); + return slots; +} export async function createHarvestAction(formData: FormData): Promise { const admin = await requireAdmin(); @@ -26,7 +41,7 @@ export async function createHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), - images: strArray(formData, "images").filter(isValidUploadPath), + images: imageSlots(formData), isPublished, }, }); @@ -56,7 +71,7 @@ export async function updateHarvestAction(formData: FormData): Promise { harvestDate: new Date(str(formData, "harvestDate")), location: str(formData, "location") || "Altenmünster", description: str(formData, "description"), - images: strArray(formData, "images").filter(isValidUploadPath), + images: imageSlots(formData), isPublished, }, }); @@ -147,7 +162,7 @@ export async function createBatchAction(formData: FormData): Promise { bestBeforeDate: new Date(str(formData, "bestBeforeDate")), glassCount: num(formData, "glassCount"), remainingGlasses: num(formData, "remainingGlasses"), - images: strArray(formData, "images").filter(isValidUploadPath), + images: imageSlots(formData), }, }); @@ -168,7 +183,7 @@ export async function updateBatchAction(formData: FormData): Promise { bestBeforeDate: new Date(str(formData, "bestBeforeDate")), glassCount: num(formData, "glassCount"), remainingGlasses: num(formData, "remainingGlasses"), - images: strArray(formData, "images").filter(isValidUploadPath), + images: imageSlots(formData), }, }); diff --git a/src/app/admin/actions/image-slots.test.ts b/src/app/admin/actions/image-slots.test.ts new file mode 100644 index 0000000..2aa26f8 --- /dev/null +++ b/src/app/admin/actions/image-slots.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { strArray, strArraySlots } from "@/lib/form-fields"; + +/** + * Chargen- und Erntebilder werden ueber ZWEI Formularfelder mit demselben + * Namen "images" uebermittelt — die Position entscheidet, welche Kachel auf + * /charge/[code] gefuellt wird (Slot 1 links, Slot 2 rechts/Standort). + * strArraySlots muss diese Position erhalten, strArray darf es weiterhin + * nicht (Produktbilder sind eine echte, lueckenlose Liste). + */ +function formWith(...images: string[]): FormData { + const formData = new FormData(); + for (const image of images) formData.append("images", image); + return formData; +} + +describe("strArraySlots", () => { + it("haelt einen leeren ersten Slot offen, damit das zweite Bild in der rechten Kachel landet", () => { + const slots = strArraySlots(formWith("", "/uploads/standort.webp"), "images"); + + // Genau der Fall, der vorher unmoeglich war: nur das Standortbild pflegen. + expect(slots).toEqual(["", "/uploads/standort.webp"]); + }); + + it("laesst strArray denselben Fall weiterhin zusammenrutschen", () => { + // Gegenprobe zur Begruendung der zweiten Funktion: mit strArray waere das + // Standortbild auf Position 0 gerutscht und in der linken Kachel gelandet. + expect(strArray(formWith("", "/uploads/standort.webp"), "images")).toEqual([ + "/uploads/standort.webp", + ]); + }); + + it("behaelt beide Slots, wenn beide gefuellt sind", () => { + expect(strArraySlots(formWith("/uploads/a.webp", "/uploads/b.webp"), "images")).toEqual([ + "/uploads/a.webp", + "/uploads/b.webp", + ]); + }); + + it("entfernt am Ende stehende leere Slots", () => { + // Nur das erste Bild gepflegt: der leere zweite Slot muss weg, sonst + // stuende in der Datenbank ["/uploads/a.webp", ""] statt eines Eintrags. + expect(strArraySlots(formWith("/uploads/a.webp", ""), "images")).toEqual([ + "/uploads/a.webp", + ]); + }); + + it("ergibt fuer ein vollstaendig leeres Feld ein leeres Array, nicht zwei leere Slots", () => { + expect(strArraySlots(formWith("", ""), "images")).toEqual([]); + }); + + it("schneidet Leerzeichen ab", () => { + expect(strArraySlots(formWith(" /uploads/a.webp ", " "), "images")).toEqual([ + "/uploads/a.webp", + ]); + }); + + it("liefert ein leeres Array, wenn das Feld gar nicht vorkommt", () => { + expect(strArraySlots(new FormData(), "images")).toEqual([]); + }); +}); diff --git a/src/app/admin/chargen/[id]/page.tsx b/src/app/admin/chargen/[id]/page.tsx index 9de8387..c45252f 100644 --- a/src/app/admin/chargen/[id]/page.tsx +++ b/src/app/admin/chargen/[id]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; import { BackLink } from "@/components/back-link"; -import { AdminImageListField } from "@/components/admin-image-list-field"; +import { AdminImageField } from "@/components/admin-image-field"; import { updateBatchAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -67,11 +67,27 @@ export default async function AdminBatchEditPage(props: PageProps<"/admin/charge /> - + {/* Zwei einzeln setzbare Slots statt einer Liste: die Position + entscheidet, welche Kachel auf /charge/[code] gefuellt wird. + Beide tragen denselben Feldnamen "images" und werden in dieser + Reihenfolge uebermittelt (siehe imageSlots() in + admin/actions/harvests-batches.ts). Ein leerer Slot faellt auf + das entsprechende Erntebild zurueck. */} +
+ {t.adminBatchEditPage.imagesLabel} +
+ + +
+
diff --git a/src/app/admin/ernten/[id]/page.tsx b/src/app/admin/ernten/[id]/page.tsx index b19b50d..fc31d52 100644 --- a/src/app/admin/ernten/[id]/page.tsx +++ b/src/app/admin/ernten/[id]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from "next/navigation"; import { prisma } from "@/lib/prisma"; import { asStringArray } from "@/lib/json-array"; import { BackLink } from "@/components/back-link"; -import { AdminImageListField } from "@/components/admin-image-list-field"; +import { AdminImageField } from "@/components/admin-image-field"; import { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions"; import { getLocale } from "@/i18n/locale"; import { getDictionary } from "@/i18n/get-dictionary"; @@ -85,11 +85,25 @@ export default async function AdminHarvestEditPage(props: PageProps<"/admin/ernt className="mt-1 w-full border border-ink/20 bg-background px-3 py-2" /> - + {/* Siehe Kommentar im Chargen-Formular: zwei benannte Slots statt + einer Liste, damit sich das zweite Bild auch ohne erstes setzen + laesst. Slot 1 erscheint zusaetzlich auf /ernten und der + Ernten-Detailseite. */} +
+ {t.adminHarvestEditPage.imagesLabel} +
+ + +
+