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} +
+ + +
+