feat: Chargen- und Erntebilder einzeln pro Kachel pflegbar

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-15 21:10:12 +02:00
1 parent 979d39e677
commit ff365c3cb1
12 files changed
+178 -23

No files matched your search

+6 -6
View File
@@ -59,9 +59,9 @@ export function strOrNull(formData: FormData, key: string): string | null {
* Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben * Inputs aus AdminImageListField, je Bild ein eigenes Feld mit demselben
* Namen) als getrimmtes String-Array ohne leere Eintraege. * Namen) als getrimmtes String-Array ohne leere Eintraege.
*/ */
export function strArray(formData: FormData, key: string): string[] { // Reine FormData-Leser ohne Datenbankbezug liegen in lib/form-fields.ts,
return formData // damit sie sich ohne DATABASE_URL testen lassen (_shared.ts zieht ueber
.getAll(key) // lib/prisma.ts einen Datenbank-Adapter nach, sobald es importiert wird).
.map((value) => String(value).trim()) // Re-Export, damit alle bestehenden Importe aus dieser Datei weiterhin
.filter(Boolean); // funktionieren.
} export { strArray, strArraySlots } from "@/lib/form-fields";
+20 -5
View File
@@ -5,7 +5,22 @@ import { revalidatePath } from "next/cache";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { logAdminAction } from "@/lib/audit-log"; import { logAdminAction } from "@/lib/audit-log";
import { isValidUploadPath } from "@/lib/uploads"; 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<void> { export async function createHarvestAction(formData: FormData): Promise<void> {
const admin = await requireAdmin(); const admin = await requireAdmin();
@@ -26,7 +41,7 @@ export async function createHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")), harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
isPublished, isPublished,
}, },
}); });
@@ -56,7 +71,7 @@ export async function updateHarvestAction(formData: FormData): Promise<void> {
harvestDate: new Date(str(formData, "harvestDate")), harvestDate: new Date(str(formData, "harvestDate")),
location: str(formData, "location") || "Altenmünster", location: str(formData, "location") || "Altenmünster",
description: str(formData, "description"), description: str(formData, "description"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
isPublished, isPublished,
}, },
}); });
@@ -147,7 +162,7 @@ export async function createBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")), bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"), glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"), remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
}, },
}); });
@@ -168,7 +183,7 @@ export async function updateBatchAction(formData: FormData): Promise<void> {
bestBeforeDate: new Date(str(formData, "bestBeforeDate")), bestBeforeDate: new Date(str(formData, "bestBeforeDate")),
glassCount: num(formData, "glassCount"), glassCount: num(formData, "glassCount"),
remainingGlasses: num(formData, "remainingGlasses"), remainingGlasses: num(formData, "remainingGlasses"),
images: strArray(formData, "images").filter(isValidUploadPath), images: imageSlots(formData),
}, },
}); });
+61
View File
@@ -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([]);
});
});
+20 -4
View File
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; 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 { updateBatchAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; import { getDictionary } from "@/i18n/get-dictionary";
@@ -67,11 +67,27 @@ export default async function AdminBatchEditPage(props: PageProps<"/admin/charge
/> />
</label> </label>
</div> </div>
<AdminImageListField {/* 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. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminBatchEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images" name="images"
label={t.adminBatchEditPage.imagesLabel} label={t.adminBatchEditPage.image1Label}
initialValues={batch.images} initialValue={batch.images[0] ?? ""}
/> />
<AdminImageField
name="images"
label={t.adminBatchEditPage.image2Label}
initialValue={batch.images[1] ?? ""}
/>
</div>
</fieldset>
<button type="submit" className="bg-forest text-cream px-6 py-2 text-sm"> <button type="submit" className="bg-forest text-cream px-6 py-2 text-sm">
{t.adminBatchEditPage.save} {t.adminBatchEditPage.save}
</button> </button>
+18 -4
View File
@@ -2,7 +2,7 @@ import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { asStringArray } from "@/lib/json-array"; import { asStringArray } from "@/lib/json-array";
import { BackLink } from "@/components/back-link"; 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 { updateHarvestAction, toggleHarvestPublishedAction } from "../../actions";
import { getLocale } from "@/i18n/locale"; import { getLocale } from "@/i18n/locale";
import { getDictionary } from "@/i18n/get-dictionary"; 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" className="mt-1 w-full border border-ink/20 bg-background px-3 py-2"
/> />
</label> </label>
<AdminImageListField {/* 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. */}
<fieldset className="border border-ink/10 p-4">
<legend className="px-1 text-sm text-ink/70">{t.adminHarvestEditPage.imagesLabel}</legend>
<div className="space-y-4">
<AdminImageField
name="images" name="images"
label={t.adminHarvestEditPage.imagesLabel} label={t.adminHarvestEditPage.image1Label}
initialValues={harvest.images} initialValue={harvest.images[0] ?? ""}
/> />
<AdminImageField
name="images"
label={t.adminHarvestEditPage.image2Label}
initialValue={harvest.images[1] ?? ""}
/>
</div>
</fieldset>
<label className="flex items-center gap-2 text-sm text-ink/70"> <label className="flex items-center gap-2 text-sm text-ink/70">
<input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} /> <input type="checkbox" name="isPublished" defaultChecked={harvest.isPublished} />
{t.adminHarvestEditPage.isPublishedLabel} {t.adminHarvestEditPage.isPublishedLabel}
+6 -2
View File
@@ -44,9 +44,13 @@ export default async function BatchPage(props: PageProps<"/charge/[code]">) {
// das entsprechende Erntebild zurueck. Bewusst NICHT alles-oder-nichts: // das entsprechende Erntebild zurueck. Bewusst NICHT alles-oder-nichts:
// wer nur ein Chargenfoto pflegt, soll nicht das zweite (Standort-)Bild // wer nur ein Chargenfoto pflegt, soll nicht das zweite (Standort-)Bild
// verlieren, nur weil die Charge kein zweites eigenes Foto hat. // verlieren, nur weil die Charge kein zweites eigenes Foto hat.
// "||" statt "??": ein im Admin geleerter Slot kommt als leerer String
// an, nicht als undefined (siehe imageSlots() in
// admin/actions/harvests-batches.ts). Mit "??" wuerde dieser leere String
// als gueltiger Wert durchgehen und das Erntebild darunter verdecken.
const displayImages = [ const displayImages = [
batch.images[0] ?? batch.harvest.images[0] ?? null, batch.images[0] || batch.harvest.images[0] || null,
batch.images[1] ?? batch.harvest.images[1] ?? null, batch.images[1] || batch.harvest.images[1] || null,
]; ];
return ( return (
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function HarvestDetailPage(
<Container className="grid md:grid-cols-2 gap-14"> <Container className="grid md:grid-cols-2 gap-14">
<ScrollReveal> <ScrollReveal>
<ContentImage <ContentImage
src={harvest.images[0] ?? null} src={harvest.images[0] || null}
alt={harvest.type} alt={harvest.type}
tone="forest" tone="forest"
className="aspect-[4/5] w-full" className="aspect-[4/5] w-full"
+1 -1
View File
@@ -48,7 +48,7 @@ export default async function ErntenPage() {
className="group block" className="group block"
> >
<ContentImage <ContentImage
src={harvest.images[0] ?? null} src={harvest.images[0] || null}
alt={harvest.type} alt={harvest.type}
tone="forest" tone="forest"
className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]" className="aspect-[4/3] w-full transition-transform duration-500 group-hover:scale-[1.02]"
+4
View File
@@ -842,6 +842,8 @@ export const de: Dictionary = {
glassCountLabel: "Anzahl Gläser", glassCountLabel: "Anzahl Gläser",
remainingGlassesLabel: "Verbleibender Bestand", remainingGlassesLabel: "Verbleibender Bestand",
imagesLabel: "Chargen-Bilder", imagesLabel: "Chargen-Bilder",
image1Label: "Bild 1 — linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
save: "Speichern", save: "Speichern",
}, },
trackOrderPage: { trackOrderPage: {
@@ -921,6 +923,8 @@ export const de: Dictionary = {
locationLabel: "Standort", locationLabel: "Standort",
descriptionLabel: "Beschreibung", descriptionLabel: "Beschreibung",
imagesLabel: "Erntebilder", imagesLabel: "Erntebilder",
image1Label: "Bild 1 — Ernten-Übersicht und linke Kachel auf der Chargenseite",
image2Label: "Bild 2 — rechte Kachel auf der Chargenseite (Standort)",
isPublishedLabel: "Öffentlich sichtbar", isPublishedLabel: "Öffentlich sichtbar",
isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.", isPublishedHint: "Steuert, ob diese Ernte auf der Website (Shop, /ernten) sichtbar ist.",
draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.", draftNotice: "Diese Ernte ist aktuell nur im Admin-Bereich sichtbar (z.B. aus ApiCore importiert). Bitte Angaben prüfen und danach veröffentlichen.",
+4
View File
@@ -842,6 +842,8 @@ export const en: Dictionary = {
glassCountLabel: "Number of jars", glassCountLabel: "Number of jars",
remainingGlassesLabel: "Remaining stock", remainingGlassesLabel: "Remaining stock",
imagesLabel: "Batch images", imagesLabel: "Batch images",
image1Label: "Image 1 — left tile on the batch page",
image2Label: "Image 2 — right tile on the batch page (location)",
save: "Save", save: "Save",
}, },
trackOrderPage: { trackOrderPage: {
@@ -921,6 +923,8 @@ export const en: Dictionary = {
locationLabel: "Location", locationLabel: "Location",
descriptionLabel: "Description", descriptionLabel: "Description",
imagesLabel: "Harvest images", imagesLabel: "Harvest images",
image1Label: "Image 1 — harvest overview and left tile on the batch page",
image2Label: "Image 2 — right tile on the batch page (location)",
isPublishedLabel: "Publicly visible", isPublishedLabel: "Publicly visible",
isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).", isPublishedHint: "Controls whether this harvest shows up on the website (shop, /ernten).",
draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.", draftNotice: "This harvest is currently only visible in the admin area (e.g. imported from ApiCore). Please review the details, then publish it.",
+4
View File
@@ -544,6 +544,8 @@ export interface Dictionary {
glassCountLabel: string; glassCountLabel: string;
remainingGlassesLabel: string; remainingGlassesLabel: string;
imagesLabel: string; imagesLabel: string;
image1Label: string;
image2Label: string;
save: string; save: string;
}; };
trackOrderPage: { trackOrderPage: {
@@ -622,6 +624,8 @@ export interface Dictionary {
locationLabel: string; locationLabel: string;
descriptionLabel: string; descriptionLabel: string;
imagesLabel: string; imagesLabel: string;
image1Label: string;
image2Label: string;
isPublishedLabel: string; isPublishedLabel: string;
isPublishedHint: string; isPublishedHint: string;
draftNotice: string; draftNotice: string;
+33
View File
@@ -0,0 +1,33 @@
/**
* Reine Leser fuer FormData-Werte — ohne Datenbank- oder Next.js-Bezug,
* damit sie sich unabhaengig testen lassen (siehe
* app/admin/actions/image-slots.test.ts). Die Admin-Actions importieren sie
* weiterhin ueber admin/actions/_shared.ts, das sie re-exportiert.
*/
/** Alle Werte eines wiederholten Feldes, getrimmt, ohne leere Eintraege. */
export function strArray(formData: FormData, key: string): string[] {
return formData
.getAll(key)
.map((value) => String(value).trim())
.filter(Boolean);
}
/**
* Wie strArray, aber POSITIONSERHALTEND: ein leerer Eintrag bleibt als ""
* stehen, statt herauszufallen.
*
* Gedacht fuer Felder, bei denen die Position eine Bedeutung traegt. Chargen-
* und Erntebilder sind genau so ein Fall: Slot 1 fuellt die linke, Slot 2 die
* rechte Kachel auf /charge/[code]. Mit strArray liesse sich das zweite Bild
* gar nicht allein setzen — ohne erstes Bild rueckte das zweite auf Position 0
* und landete in der falschen Kachel.
*
* Nur am Ende stehende leere Slots werden entfernt, damit ein vollstaendig
* leeres Feld weiterhin ein leeres Array ergibt statt ["", ""].
*/
export function strArraySlots(formData: FormData, key: string): string[] {
const slots = formData.getAll(key).map((value) => String(value).trim());
while (slots.length > 0 && slots[slots.length - 1] === "") slots.pop();
return slots;
}