Build Admin Artikel form — camera-first photo upload and edit #10

Open
opened 2026-05-05 10:58:00 +02:00 by marcel · 8 comments
Owner

Task 10 — Plan reference: docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md

User story (US-ADM-001 + US-ADM-002):

  • As an admin, I can add a new article with a camera-first photo upload flow on mobile.
  • As an admin, I can edit an existing article, add more photos, and delete individual photos.

Acceptance criteria

Add article (/admin/inventar/neu):

  • Large camera button opens device camera on mobile (<input type="file" capture="environment">)
  • Desktop fallback: "Datei auswählen" link opens standard file picker
  • Kategorie dropdown is required (8 fixed categories from KATEGORIEN)
  • Titel and Notiz fields are optional
  • On submit: INSERT INTO artikel, then processAndSavePhoto() for each valid file, INSERT INTO artikel_fotos with sequential positions
  • Sticky save bar at bottom with "Abbrechen" and "Speichern" buttons

Edit article (/admin/inventar/[id]/bearbeiten):

  • Pre-fills existing kategorie, titel, notiz
  • Shows existing photos as thumbnail strip; each has an ✕ button
  • ✕ button triggers ?/fotoLoeschen action: deletes from artikel_fotos and from disk
  • New photo upload adds to existing photos (appends position)
  • ?/speichern action updates artikel fields and appends new photos
  • Returns 404 for unknown [id]
  • First photo in strip is the gallery thumbnail (position = 0); shown with "1." badge

Files to create

  • src/routes/admin/inventar/neu/+page.svelte
  • src/routes/admin/inventar/neu/+page.server.ts
  • src/routes/admin/inventar/[id]/bearbeiten/+page.svelte
  • src/routes/admin/inventar/[id]/bearbeiten/+page.server.ts

Depends on: #9 | Size: M | Spec: reservierung-design §5.2, views spec View 05 + 06

## Task 10 — Plan reference: `docs/superpowers/plans/2026-05-05-erbstuecke-wannsee.md` **User story (US-ADM-001 + US-ADM-002):** - As an admin, I can add a new article with a camera-first photo upload flow on mobile. - As an admin, I can edit an existing article, add more photos, and delete individual photos. ### Acceptance criteria **Add article (`/admin/inventar/neu`):** - [ ] Large camera button opens device camera on mobile (`<input type="file" capture="environment">`) - [ ] Desktop fallback: "Datei auswählen" link opens standard file picker - [ ] Kategorie dropdown is required (8 fixed categories from `KATEGORIEN`) - [ ] Titel and Notiz fields are optional - [ ] On submit: `INSERT INTO artikel`, then `processAndSavePhoto()` for each valid file, `INSERT INTO artikel_fotos` with sequential positions - [ ] Sticky save bar at bottom with "Abbrechen" and "Speichern" buttons **Edit article (`/admin/inventar/[id]/bearbeiten`):** - [ ] Pre-fills existing kategorie, titel, notiz - [ ] Shows existing photos as thumbnail strip; each has an ✕ button - [ ] ✕ button triggers `?/fotoLoeschen` action: deletes from `artikel_fotos` and from disk - [ ] New photo upload adds to existing photos (appends position) - [ ] `?/speichern` action updates artikel fields and appends new photos - [ ] Returns 404 for unknown `[id]` - [ ] First photo in strip is the gallery thumbnail (position = 0); shown with "1." badge ### Files to create - `src/routes/admin/inventar/neu/+page.svelte` - `src/routes/admin/inventar/neu/+page.server.ts` - `src/routes/admin/inventar/[id]/bearbeiten/+page.svelte` - `src/routes/admin/inventar/[id]/bearbeiten/+page.server.ts` **Depends on:** #9 | **Size:** M | **Spec:** reservierung-design §5.2, views spec View 05 + 06
marcel added this to the v1.0 — MVP milestone 2026-05-05 10:58:00 +02:00
Author
Owner

👤 Markus Keller — Application Architect

Observations

  • The issue correctly models the two pages (/neu, /[id]/bearbeiten) as separate routes with separate +page.server.ts files. This is the right call — the load shape differs (neu has no prefill, bearbeiten must load existing artikel + fotos).
  • The spec note "gleiche Svelte-Komponente für /neu und /[id]/bearbeiten" (views spec impl note) is a good pattern — a shared ArtikelForm.svelte with optional prefill props keeps logic DRY. Confirm this component receives exactly { artikel?: Artikel, fotos?: Foto[] } — no entire data object.
  • The ?/fotoLoeschen action deleting from disk + DB in bearbeiten/+page.server.ts is correct placement. Do not hoist this to a separate API route.
  • Sequential position assignment on new photo appends: the spec defines position = 0 as the gallery thumbnail. When appending to an existing article, the correct position value is MAX(position) + 1 — not COUNT(fotos), because deleting a middle photo leaves a gap. Use SELECT MAX(position) FROM artikel_fotos WHERE artikel_id = ? before insert.
  • The INSERT INTO artikel must happen before processAndSavePhoto() because the upload path is uploads/{artikel_id}/{uuid}.webp — you need the article ID from the INSERT to build the directory. The issue's stated order is correct.
  • The ?/speichern action for the edit form updates drei fields (kategorie, titel, notiz) via a single UPDATE artikel SET ... WHERE id = ?. Do not split into multiple statements — one statement, one round-trip.
  • 404 guard in bearbeiten load: use error(404, 'Nicht gefunden') — do not redirect. A redirect to /admin/inventar for unknown IDs hides the bug during development.
  • The artikel_fotos table has UNIQUE(artikel_id, position). Position reuse after deletion will silently fail with a constraint error. The append strategy must use MAX not COUNT.

Recommendations

  • Define the load shape for bearbeiten explicitly before implementing: { artikel: Artikel, fotos: Foto[] }. Keep the server types in src/lib/types.ts.
  • Extract a stmtMaxFotoPosition prepared statement in lib/db.ts: SELECT COALESCE(MAX(position), -1) FROM artikel_fotos WHERE artikel_id = ?. Use COALESCE(..., -1) so the first photo gets position 0.
  • The ?/fotoLoeschen action must: (1) verify the foto belongs to an article the admin owns — i.e., query artikel_fotos first, confirm artikel_id matches the route param [id], then delete. This prevents an admin from calling ?/fotoLoeschen on another article's photo via crafted form data.
  • Wrap the multi-photo upload loop in a transaction: db.transaction(() => { for (const file of files) { stmtInsertFoto.run(...); } })(). If one insert fails the whole batch rolls back cleanly.

Open Decisions (omit if none)

  • Photo reorder on edit — The system design spec mentions drag-to-reorder sending a new position-array. The issue's edit acceptance criteria do not mention reorder. Does bearbeiten support drag reorder in this ticket, or is that deferred? Cost of deferral: the first photo in the strip stays the thumbnail, which admins cannot change after initial upload without deleting and re-uploading.
## 👤 Markus Keller — Application Architect ### Observations - The issue correctly models the two pages (`/neu`, `/[id]/bearbeiten`) as separate routes with separate `+page.server.ts` files. This is the right call — the load shape differs (neu has no prefill, bearbeiten must load existing artikel + fotos). - The spec note "gleiche Svelte-Komponente für /neu und /[id]/bearbeiten" (views spec impl note) is a good pattern — a shared `ArtikelForm.svelte` with optional prefill props keeps logic DRY. Confirm this component receives exactly `{ artikel?: Artikel, fotos?: Foto[] }` — no entire `data` object. - The `?/fotoLoeschen` action deleting from disk + DB in `bearbeiten/+page.server.ts` is correct placement. Do not hoist this to a separate API route. - Sequential position assignment on new photo appends: the spec defines `position = 0` as the gallery thumbnail. When appending to an existing article, the correct position value is `MAX(position) + 1` — not `COUNT(fotos)`, because deleting a middle photo leaves a gap. Use `SELECT MAX(position) FROM artikel_fotos WHERE artikel_id = ?` before insert. - The `INSERT INTO artikel` must happen before `processAndSavePhoto()` because the upload path is `uploads/{artikel_id}/{uuid}.webp` — you need the article ID from the INSERT to build the directory. The issue's stated order is correct. - The `?/speichern` action for the edit form updates drei fields (kategorie, titel, notiz) via a single `UPDATE artikel SET ... WHERE id = ?`. Do not split into multiple statements — one statement, one round-trip. - 404 guard in bearbeiten load: use `error(404, 'Nicht gefunden')` — do not redirect. A redirect to `/admin/inventar` for unknown IDs hides the bug during development. - The `artikel_fotos` table has `UNIQUE(artikel_id, position)`. Position reuse after deletion will silently fail with a constraint error. The append strategy must use MAX not COUNT. ### Recommendations - Define the load shape for bearbeiten explicitly before implementing: `{ artikel: Artikel, fotos: Foto[] }`. Keep the server types in `src/lib/types.ts`. - Extract a `stmtMaxFotoPosition` prepared statement in `lib/db.ts`: `SELECT COALESCE(MAX(position), -1) FROM artikel_fotos WHERE artikel_id = ?`. Use `COALESCE(..., -1)` so the first photo gets position 0. - The `?/fotoLoeschen` action must: (1) verify the foto belongs to an article the admin owns — i.e., query `artikel_fotos` first, confirm `artikel_id` matches the route param `[id]`, then delete. This prevents an admin from calling `?/fotoLoeschen` on another article's photo via crafted form data. - Wrap the multi-photo upload loop in a transaction: `db.transaction(() => { for (const file of files) { stmtInsertFoto.run(...); } })()`. If one insert fails the whole batch rolls back cleanly. ### Open Decisions _(omit if none)_ - **Photo reorder on edit** — The system design spec mentions drag-to-reorder sending a new `position`-array. The issue's edit acceptance criteria do not mention reorder. Does bearbeiten support drag reorder in this ticket, or is that deferred? Cost of deferral: the first photo in the strip stays the thumbnail, which admins cannot change after initial upload without deleting and re-uploading.
Author
Owner

👤 Felix Brandt — Fullstack Developer

Observations

  • The issue specifies processAndSavePhoto() for each valid file in the ?/speichern action. "Valid file" needs a concrete definition at the action boundary: size check (file.size > 0) and MIME check (image/jpeg | image/png | image/webp) before calling sharp. Silently skipping zero-byte files from a multi-select is the right UX.
  • The spec note says "Fotos als createObjectURL()-Preview im Strip, Upload erst beim Speichern via Multipart Form Action". This means the Svelte component holds new photos as File objects in $state and only submits them in the multipart form on save. The hidden <input type="file" accept="image/*" capture="environment" multiple> should have bind:files — but Svelte 5 uses $bindable for this. Confirm the file input approach: either bind:files on the hidden input or a JavaScript-controlled FileList via $state<File[]>.
  • The ?/fotoLoeschen action is a Form Action in the bearbeiten page. It must return after deletion rather than redirect — SvelteKit will invalidate and reload the page data. Use return {} (not redirect) so the photo strip updates reactively via use:enhance.
  • The sticky save bar with "Abbrechen" and "Speichern": "Abbrechen" should navigate to /admin/inventar — not history.back(). On mobile, history.back() may exit the admin section if the user arrived from a deep link.
  • The thumbnail strip "✕ button triggers ?/fotoLoeschen": each ✕ needs its own <form method="POST" action="?/fotoLoeschen" use:enhance> with a hidden foto_id input. Do not use a single shared form with JavaScript to swap the hidden value — it breaks without JS.
  • {#each fotos as foto (foto.id)} — keyed iteration is mandatory. Reordering without a key corrupts local drag state.
  • The form for the edit page must not use bind:value on kategorie, titel, notiz and then send them only via the multipart form — SvelteKit's request.formData() on the server receives the multipart fields. The native <select> and <input> elements just need correct name attributes; no JS binding needed for the submit path.

Recommendations

  • Write the test for ?/speichern (insert path) first: given a valid multipart POST with one image file, the action inserts one artikel row and one artikel_fotos row, and returns no error. Use a :memory: database and a mock Buffer for the photo (skip the actual sharp call in unit tests via a thin wrapper).
  • Write the test for ?/fotoLoeschen: given a foto_id that belongs to this article, the row is deleted and the file unlink is called. Given a foto_id from a different article, the action returns fail(403).
  • For the "Datei auswählen" desktop fallback: trigger the same hidden <input> but without the capture attribute on desktop. Consider a single <input> without capture at all — capture="environment" on desktop browsers opens the camera app on some systems, which is unexpected. The spec says capture="environment" on the input but the camera button triggers it via .click(). On desktop, the file picker dialog opens as fallback naturally. Keep one input; use JS to distinguish if needed.
  • Clean up uploaded files on action failure: if stmtInsertArtikel succeeds but a subsequent stmtInsertFoto.run() throws, unlink the already-written WebP file. Wrap in try/catch with await unlink(dest).catch(() => {}).

Open Decisions (omit if none)

  • capture="environment" on the single file input — If the hidden input always has capture="environment", desktop browsers may open camera apps instead of file pickers. Should the "Datei auswählen" link trigger a separate <input> without capture, or should it remove the capture attribute dynamically via JS before triggering .click()? Both work; the question is implementation clarity vs. HTML purity. (Raised by: Felix Brandt)
## 👤 Felix Brandt — Fullstack Developer ### Observations - The issue specifies `processAndSavePhoto()` for each valid file in the `?/speichern` action. "Valid file" needs a concrete definition at the action boundary: size check (`file.size > 0`) and MIME check (`image/jpeg | image/png | image/webp`) before calling sharp. Silently skipping zero-byte files from a multi-select is the right UX. - The spec note says "Fotos als `createObjectURL()`-Preview im Strip, Upload erst beim Speichern via Multipart Form Action". This means the Svelte component holds new photos as `File` objects in `$state` and only submits them in the multipart form on save. The hidden `<input type="file" accept="image/*" capture="environment" multiple>` should have `bind:files` — but Svelte 5 uses `$bindable` for this. Confirm the file input approach: either `bind:files` on the hidden input or a JavaScript-controlled `FileList` via `$state<File[]>`. - The `?/fotoLoeschen` action is a Form Action in the bearbeiten page. It must return after deletion rather than redirect — SvelteKit will invalidate and reload the page data. Use `return {}` (not `redirect`) so the photo strip updates reactively via `use:enhance`. - The sticky save bar with "Abbrechen" and "Speichern": "Abbrechen" should navigate to `/admin/inventar` — not `history.back()`. On mobile, `history.back()` may exit the admin section if the user arrived from a deep link. - The thumbnail strip "✕ button triggers `?/fotoLoeschen`": each ✕ needs its own `<form method="POST" action="?/fotoLoeschen" use:enhance>` with a hidden `foto_id` input. Do not use a single shared form with JavaScript to swap the hidden value — it breaks without JS. - `{#each fotos as foto (foto.id)}` — keyed iteration is mandatory. Reordering without a key corrupts local drag state. - The form for the edit page must not use `bind:value` on kategorie, titel, notiz and then send them only via the multipart form — SvelteKit's `request.formData()` on the server receives the multipart fields. The native `<select>` and `<input>` elements just need correct `name` attributes; no JS binding needed for the submit path. ### Recommendations - Write the test for `?/speichern` (insert path) first: given a valid multipart POST with one image file, the action inserts one `artikel` row and one `artikel_fotos` row, and returns no error. Use a `:memory:` database and a mock Buffer for the photo (skip the actual sharp call in unit tests via a thin wrapper). - Write the test for `?/fotoLoeschen`: given a foto_id that belongs to this article, the row is deleted and the file unlink is called. Given a foto_id from a different article, the action returns `fail(403)`. - For the "Datei auswählen" desktop fallback: trigger the same hidden `<input>` but without the `capture` attribute on desktop. Consider a single `<input>` without `capture` at all — `capture="environment"` on desktop browsers opens the camera app on some systems, which is unexpected. The spec says `capture="environment"` on the input but the camera button triggers it via `.click()`. On desktop, the file picker dialog opens as fallback naturally. Keep one input; use JS to distinguish if needed. - Clean up uploaded files on action failure: if `stmtInsertArtikel` succeeds but a subsequent `stmtInsertFoto.run()` throws, unlink the already-written WebP file. Wrap in try/catch with `await unlink(dest).catch(() => {})`. ### Open Decisions _(omit if none)_ - **`capture="environment"` on the single file input** — If the hidden input always has `capture="environment"`, desktop browsers may open camera apps instead of file pickers. Should the "Datei auswählen" link trigger a separate `<input>` without `capture`, or should it remove the `capture` attribute dynamically via JS before triggering `.click()`? Both work; the question is implementation clarity vs. HTML purity. _(Raised by: Felix Brandt)_
Author
Owner

👤 Nora "NullX" Steiner — Security Engineer

Observations

The attack surface for this feature is photo upload + file deletion. Both vectors are present and need explicit guards.

1. File type validation — client-supplied MIME is untrustworthy
file.type in a multipart upload comes from the browser's Content-Type header for that part, which an attacker can forge. The issue says "each valid file" without defining validity. The implementation must inspect file bytes:

import { fileTypeFromBuffer } from 'file-type';
const bytes = Buffer.from(await file.arrayBuffer());
const detected = await fileTypeFromBuffer(bytes);
if (!detected || !['image/jpeg', 'image/png', 'image/webp'].includes(detected.mime)) {
  return fail(400, { error: 'Ungültiger Dateityp' });
}

2. Size check before reading into memory
file.size > 20 * 1024 * 1024 must be checked before file.arrayBuffer() — not after. Reading a 500 MB file into a Buffer before rejecting it is a memory exhaustion vector.

3. IDOR on ?/fotoLoeschen
The action receives a foto_id. Without an ownership check, an admin could craft a POST to delete a photo belonging to any article — not just the one at [id]. The guard:

const foto = stmtFotoById.get(fotoId);
if (!foto || foto.artikel_id !== Number(params.id)) {
  return fail(403, { error: 'Foto gehört nicht zu diesem Artikel' });
}

This is CWE-639 (Authorization Bypass Through User-Controlled Key). It's real even in a single-admin context because the admin session cookie is not user-specific — any of the three admins could call it.

4. Path traversal in upload write path
The processAndSavePhoto() function builds: path.join(UPLOAD_DIR, String(artikelId), filename). artikelId comes from the database (safe — it's an integer from the INSERT RETURNING). filename is ${randomUUID()}.webp (safe — server-generated). The path is clean as long as these invariants hold. Confirm the implementation never uses any user-supplied string in the path.

5. Admin auth guard before any action
Both +page.server.ts files are under /admin/inventar/. The +layout.server.ts auth guard covers the load functions. However, Form Actions in SvelteKit bypass the layout load — the +layout.server.ts guard does NOT run for Form Actions by default if hooks.server.ts sets locals.admin and actions check it. Confirm each action begins with:

if (!locals.admin) redirect(303, '/admin/login');

Recommendations

  • Add file-type as a dependency. Without byte-level validation, a crafted file with a .jpg MIME header but executable content passes through to sharp. Sharp will reject most non-images, but the defense layer should not rely on sharp's error handling for security.
  • The size check (if (file.size > 20_000_000) return fail(400, ...)) must be the first check, before any arrayBuffer() call.
  • Add explicit if (!locals.admin) redirect(303, '/admin/login') as the first line of every Form Action, even under the auth-guarded layout. Defense in depth.
  • The IDOR check on fotoLoeschen is a required security control, not optional. Add a failing test for it before implementing the action.
## 👤 Nora "NullX" Steiner — Security Engineer ### Observations The attack surface for this feature is photo upload + file deletion. Both vectors are present and need explicit guards. **1. File type validation — client-supplied MIME is untrustworthy** `file.type` in a multipart upload comes from the browser's Content-Type header for that part, which an attacker can forge. The issue says "each valid file" without defining validity. The implementation must inspect file bytes: ```typescript import { fileTypeFromBuffer } from 'file-type'; const bytes = Buffer.from(await file.arrayBuffer()); const detected = await fileTypeFromBuffer(bytes); if (!detected || !['image/jpeg', 'image/png', 'image/webp'].includes(detected.mime)) { return fail(400, { error: 'Ungültiger Dateityp' }); } ``` **2. Size check before reading into memory** `file.size > 20 * 1024 * 1024` must be checked before `file.arrayBuffer()` — not after. Reading a 500 MB file into a Buffer before rejecting it is a memory exhaustion vector. **3. IDOR on `?/fotoLoeschen`** The action receives a `foto_id`. Without an ownership check, an admin could craft a POST to delete a photo belonging to any article — not just the one at `[id]`. The guard: ```typescript const foto = stmtFotoById.get(fotoId); if (!foto || foto.artikel_id !== Number(params.id)) { return fail(403, { error: 'Foto gehört nicht zu diesem Artikel' }); } ``` This is CWE-639 (Authorization Bypass Through User-Controlled Key). It's real even in a single-admin context because the admin session cookie is not user-specific — any of the three admins could call it. **4. Path traversal in upload write path** The `processAndSavePhoto()` function builds: `path.join(UPLOAD_DIR, String(artikelId), filename)`. `artikelId` comes from the database (safe — it's an integer from the INSERT RETURNING). `filename` is `${randomUUID()}.webp` (safe — server-generated). The path is clean as long as these invariants hold. Confirm the implementation never uses any user-supplied string in the path. **5. Admin auth guard before any action** Both `+page.server.ts` files are under `/admin/inventar/`. The `+layout.server.ts` auth guard covers the load functions. However, Form Actions in SvelteKit bypass the layout load — the `+layout.server.ts` guard does NOT run for Form Actions by default if `hooks.server.ts` sets `locals.admin` and actions check it. Confirm each action begins with: ```typescript if (!locals.admin) redirect(303, '/admin/login'); ``` ### Recommendations - Add `file-type` as a dependency. Without byte-level validation, a crafted file with a `.jpg` MIME header but executable content passes through to sharp. Sharp will reject most non-images, but the defense layer should not rely on sharp's error handling for security. - The size check (`if (file.size > 20_000_000) return fail(400, ...)`) must be the first check, before any `arrayBuffer()` call. - Add explicit `if (!locals.admin) redirect(303, '/admin/login')` as the first line of every Form Action, even under the auth-guarded layout. Defense in depth. - The IDOR check on `fotoLoeschen` is a required security control, not optional. Add a failing test for it before implementing the action.
Author
Owner

👤 Sara Holt — QA Engineer

Observations

This feature has a notably complex test surface: two routes, two form actions in bearbeiten, one action in neu, file I/O, database writes, and a UI state machine (no-photo state → strip state). The issue has no test plan.

Required test layers:

Unit (lib/photos.ts):

  • processAndSavePhoto() with a valid JPEG buffer produces a WebP file at the expected path
  • processAndSavePhoto() with an oversized buffer returns an error (or throws) before writing
  • File cleanup on DB write failure (unlink is called when stmtInsertFoto throws)

Integration (:memory: SQLite, Vitest):

  • ?/speichern (neu): inserts artikel row + one artikel_fotos row per valid file; returns no error on success
  • ?/speichern (neu): with no files attached, inserts artikel row only (zero photos is valid)
  • ?/speichern (bearbeiten): updates titel, kategorie, notiz on existing artikel; appends new fotos
  • ?/fotoLoeschen: deletes the correct artikel_fotos row; leaves other rows for the same artikel intact
  • ?/fotoLoeschen: given a foto_id from a different article, returns fail(403) (ownership check)
  • bearbeiten load: returns 404 for an unknown article ID
  • Position append uses MAX + 1 not COUNT: create article, add 3 fotos, delete position 1, add another foto — confirm new position is 3 not 2

Component (vitest-browser-svelte):

  • Renders state A (no photos): Kamera-Button visible; form fields disabled/greyed
  • Renders state B (with photos): thumbnail strip visible with correct badge on index 0; form fields enabled
  • ✕ button on thumbnail renders for each foto; clicking submits ?/fotoLoeschen
  • "Speichern" button in save bar is disabled/greyed in state A, active in state B (if that's the UX intent — confirm from spec)

E2E (Playwright — add to existing admin journey):

  • Admin adds article with one photo → article appears in /admin/inventar list with thumbnail
  • Admin edits article → existing foto shown with ✕ → deletes foto → foto strip updates → saves → inventar list updates

Recommendations

  • The position-gap test (delete middle photo, then append) is the highest-risk integration scenario. Write it first. A COUNT-based position strategy will produce a UNIQUE(artikel_id, position) constraint violation here.
  • The component state machine (state A / state B) should be tested as two separate component tests with explicit props, not one test that simulates the upload flow — the upload flow belongs in E2E.
  • Do not use page.waitForTimeout() after the ?/fotoLoeschen form submission. Use expect(page.getByAltText(...)).not.toBeVisible() or assert on the updated strip count.
  • Add an axe-core check to the /admin/inventar/neu page specifically — the camera button and file input have non-obvious ARIA requirements.

Open Decisions (omit if none)

  • "Speichern" button disabled before first photo? — The spec mockup (Zustand A) shows the save bar's Speichern button greyed out before a photo is taken. The issue acceptance criteria do not explicitly state this. If Speichern is always enabled, an admin can save an article with no photos (which is valid per the issue). If it is disabled until a photo exists, this needs a $derived for enable/disable state and a separate test. (Raised by: Sara Holt)
## 👤 Sara Holt — QA Engineer ### Observations This feature has a notably complex test surface: two routes, two form actions in bearbeiten, one action in neu, file I/O, database writes, and a UI state machine (no-photo state → strip state). The issue has no test plan. **Required test layers:** **Unit (`lib/photos.ts`):** - `processAndSavePhoto()` with a valid JPEG buffer produces a WebP file at the expected path - `processAndSavePhoto()` with an oversized buffer returns an error (or throws) before writing - File cleanup on DB write failure (unlink is called when `stmtInsertFoto` throws) **Integration (`:memory:` SQLite, Vitest):** - `?/speichern` (neu): inserts `artikel` row + one `artikel_fotos` row per valid file; returns no error on success - `?/speichern` (neu): with no files attached, inserts `artikel` row only (zero photos is valid) - `?/speichern` (bearbeiten): updates `titel`, `kategorie`, `notiz` on existing artikel; appends new fotos - `?/fotoLoeschen`: deletes the correct `artikel_fotos` row; leaves other rows for the same artikel intact - `?/fotoLoeschen`: given a `foto_id` from a different article, returns `fail(403)` (ownership check) - `bearbeiten` load: returns 404 for an unknown article ID - Position append uses MAX + 1 not COUNT: create article, add 3 fotos, delete position 1, add another foto — confirm new position is 3 not 2 **Component (vitest-browser-svelte):** - Renders state A (no photos): Kamera-Button visible; form fields disabled/greyed - Renders state B (with photos): thumbnail strip visible with correct badge on index 0; form fields enabled - ✕ button on thumbnail renders for each foto; clicking submits `?/fotoLoeschen` - "Speichern" button in save bar is disabled/greyed in state A, active in state B (if that's the UX intent — confirm from spec) **E2E (Playwright — add to existing admin journey):** - Admin adds article with one photo → article appears in `/admin/inventar` list with thumbnail - Admin edits article → existing foto shown with ✕ → deletes foto → foto strip updates → saves → inventar list updates ### Recommendations - The position-gap test (delete middle photo, then append) is the highest-risk integration scenario. Write it first. A COUNT-based position strategy will produce a `UNIQUE(artikel_id, position)` constraint violation here. - The component state machine (state A / state B) should be tested as two separate component tests with explicit props, not one test that simulates the upload flow — the upload flow belongs in E2E. - Do not use `page.waitForTimeout()` after the `?/fotoLoeschen` form submission. Use `expect(page.getByAltText(...)).not.toBeVisible()` or assert on the updated strip count. - Add an axe-core check to the `/admin/inventar/neu` page specifically — the camera button and file input have non-obvious ARIA requirements. ### Open Decisions _(omit if none)_ - **"Speichern" button disabled before first photo?** — The spec mockup (Zustand A) shows the save bar's Speichern button greyed out before a photo is taken. The issue acceptance criteria do not explicitly state this. If Speichern is always enabled, an admin can save an article with no photos (which is valid per the issue). If it is disabled until a photo exists, this needs a `$derived` for enable/disable state and a separate test. _(Raised by: Sara Holt)_
Author
Owner

👤 Leonie Voss — UX Design Lead

Observations

The spec (View 05b) defines two explicit states for the article form — "Zustand A — Kein Foto" and "Zustand B — Mit Fotos" — with distinct interaction patterns. Several of the issue's acceptance criteria leave UX details underspecified.

Camera button spec:

  • The implementation ref table gives the exact Tailwind classes: font-serif text-sm font-bold bg-primary text-white w-full min-h-[60px] rounded-xl flex items-center justify-center gap-2.5
  • The button must be min-h-[60px] — not 44px. The camera affordance is the primary action on mobile admin. 60px is intentional.
  • The "Oder Datei wählen" fallback is a plain text link — underline text-primary — not a button. It must not have button styling.

State A → State B transition:

  • In State A, the form fields (Kategorie, Titel, Notiz) are visually greyed out (opacity-40 pointer-events-none in the spec mockup). This is important — it guides the admin to take a photo before filling metadata.
  • In State B, the greyed-out overlay is removed and fields become interactive.
  • The "1." badge on the first thumbnail must use the amber accent token (bg-accent text-white text-[7px] font-extrabold px-1.5 py-0.5 rounded-sm). The spec says text "Thumbnail" — not "1." — check the implementation ref table: it says Text: „Thumbnail" — nur auf Index 0.

Thumbnail strip:

  • Thumbnails are w-[68px] h-[68px] rounded-lg object-cover flex-shrink-0 relative. The ✕ button is absolute top-1 right-1 bg-black/55 text-white w-[17px] h-[17px] rounded-full text-[9px] font-extrabold (from the spec mockup .rm CSS). It's only 17×17px — technically below 44px touch target. This is an intentional spec exception for the compact strip; document it.
  • The "Mehr" add-photo button at the end of the strip uses 📷 icon + "Mehr" label. It must also trigger the hidden <input> via .click().

Sticky save bar:

  • Tailwind: bg-surface border-t border-line p-2.5 flex gap-2 shadow-[0_-2px_10px_rgba(0,0,0,.06)] flex-shrink-0
  • "Abbrechen" = outline button; "Speichern" = filled bg-primary button. Both full-width on mobile (flex-1 each within the flex container).
  • "Speichern" must be greyed (opacity-40 cursor-not-allowed) in State A.

Typography and tokens:

  • Page title "Hinzufügen" / "Bearbeiten" in the topbar: font-sans font-semibold text-ink — not font-serif. Headings in admin UI use Inter, not Lora. Reserve Lora for article titles and the app logo.
  • Category label "Kategorie ": the asterisk () must be text-status-taken (red) per the required-field convention.

Recommendations

  • Implement state A/B as a $derived from fotos.length > 0 (where fotos is the $state<File[]> of new files + existing fotos count). Apply class:opacity-40 class:pointer-events-none to the form section based on this derived value.
  • The ✕ button on each thumbnail needs aria-label="Foto entfernen" — the icon alone (✕ character) is not accessible. Screen readers will announce "times" or "multiply" without the label.
  • The camera button needs aria-label="Foto aufnehmen" in addition to its visible text, as the 📷 emoji has inconsistent screen reader pronunciation across platforms.
  • Run axe-core on the completed page at 375px before marking done. The greyed-out State A fields must still pass contrast at opacity-40 — they may not. Consider using disabled attribute on native inputs instead of opacity, which preserves semantic state for assistive technology.
## 👤 Leonie Voss — UX Design Lead ### Observations The spec (View 05b) defines two explicit states for the article form — "Zustand A — Kein Foto" and "Zustand B — Mit Fotos" — with distinct interaction patterns. Several of the issue's acceptance criteria leave UX details underspecified. **Camera button spec:** - The implementation ref table gives the exact Tailwind classes: `font-serif text-sm font-bold bg-primary text-white w-full min-h-[60px] rounded-xl flex items-center justify-center gap-2.5` - The button must be `min-h-[60px]` — not `44px`. The camera affordance is the primary action on mobile admin. 60px is intentional. - The "Oder Datei wählen" fallback is a plain text link — `underline text-primary` — not a button. It must not have button styling. **State A → State B transition:** - In State A, the form fields (Kategorie, Titel, Notiz) are visually greyed out (`opacity-40 pointer-events-none` in the spec mockup). This is important — it guides the admin to take a photo before filling metadata. - In State B, the greyed-out overlay is removed and fields become interactive. - The "1." badge on the first thumbnail must use the amber accent token (`bg-accent text-white text-[7px] font-extrabold px-1.5 py-0.5 rounded-sm`). The spec says text "Thumbnail" — not "1." — check the implementation ref table: it says `Text: „Thumbnail" — nur auf Index 0`. **Thumbnail strip:** - Thumbnails are `w-[68px] h-[68px] rounded-lg object-cover flex-shrink-0 relative`. The ✕ button is `absolute top-1 right-1 bg-black/55 text-white w-[17px] h-[17px] rounded-full text-[9px] font-extrabold` (from the spec mockup `.rm` CSS). It's only 17×17px — technically below 44px touch target. This is an intentional spec exception for the compact strip; document it. - The "Mehr" add-photo button at the end of the strip uses `📷` icon + "Mehr" label. It must also trigger the hidden `<input>` via `.click()`. **Sticky save bar:** - Tailwind: `bg-surface border-t border-line p-2.5 flex gap-2 shadow-[0_-2px_10px_rgba(0,0,0,.06)] flex-shrink-0` - "Abbrechen" = outline button; "Speichern" = filled `bg-primary` button. Both full-width on mobile (`flex-1` each within the `flex` container). - "Speichern" must be greyed (`opacity-40 cursor-not-allowed`) in State A. **Typography and tokens:** - Page title "Hinzufügen" / "Bearbeiten" in the topbar: `font-sans font-semibold text-ink` — not `font-serif`. Headings in admin UI use Inter, not Lora. Reserve Lora for article titles and the app logo. - Category label "Kategorie *": the asterisk (*) must be `text-status-taken` (red) per the required-field convention. ### Recommendations - Implement state A/B as a `$derived` from `fotos.length > 0` (where `fotos` is the `$state<File[]>` of new files + existing fotos count). Apply `class:opacity-40 class:pointer-events-none` to the form section based on this derived value. - The ✕ button on each thumbnail needs `aria-label="Foto entfernen"` — the icon alone (✕ character) is not accessible. Screen readers will announce "times" or "multiply" without the label. - The camera button needs `aria-label="Foto aufnehmen"` in addition to its visible text, as the 📷 emoji has inconsistent screen reader pronunciation across platforms. - Run axe-core on the completed page at 375px before marking done. The greyed-out State A fields must still pass contrast at `opacity-40` — they may not. Consider using `disabled` attribute on native inputs instead of opacity, which preserves semantic state for assistive technology.
Author
Owner

👤 Tobias Wendt — DevOps & Platform Engineer

Observations

This feature introduces the first write path to the uploads named volume. Two infrastructure concerns are activated for the first time by this issue.

1. uploads volume must be declared and mounted
The docker-compose.yml must have:

volumes:
  uploads:  # named volume — survives docker compose up --build

services:
  app:
    volumes:
      - uploads:/app/uploads
    environment:
      UPLOAD_DIR: /app/uploads

If UPLOAD_DIR is not set, the processAndSavePhoto() call will write to a path that doesn't exist (or an unexpected location inside the container filesystem). The app must fail to start if UPLOAD_DIR is missing, not fail silently at upload time.

2. UPLOAD_DIR startup validation
Add to src/lib/db.ts (or app startup hook):

if (!process.env.UPLOAD_DIR) {
  console.error('UPLOAD_DIR environment variable is required');
  process.exit(1);
}

Without this, a misconfigured deploy silently writes photos to the container's ephemeral filesystem and loses them on the next docker compose up --build.

3. .env.example update
Add UPLOAD_DIR=/app/uploads to .env.example with a comment explaining it maps to the named volume mount path.

4. Backup coverage
The backup script already targets the uploads volume (erbstuecke_uploads). No change needed — the first photo upload confirms the backup path is correct. Verify after first test deploy with:

docker compose exec app ls /app/uploads/

5. Multi-stage Dockerfile — no dev dependencies in production image
sharp is a production dependency — it compiles native binaries. The multi-stage Dockerfile's runner stage installs only production deps (npm ci --omit=dev). Confirm sharp is in dependencies, not devDependencies, in package.json. A misplaced dependency will cause "Cannot find module 'sharp'" at runtime.

Recommendations

  • Add a startup validation for UPLOAD_DIR before the feature ships. One missing env var silently breaks every photo upload.
  • After first deploy with photos, verify the backup script actually includes the uploads directory: tar -tzf /opt/backups/erbstuecke/erbstuecke-latest.tar.gz | grep uploads should show entries.
  • Confirm sharp is in dependencies (not devDependencies) in package.json — this is the single most likely runtime breakage from the multi-stage Dockerfile pattern.
  • The UPLOAD_DIR path inside the container must end without a trailing slash. path.join('/app/uploads', String(id), 'file.webp') is correct; path.join('/app/uploads/', ...) also works but be consistent in .env.example.
## 👤 Tobias Wendt — DevOps & Platform Engineer ### Observations This feature introduces the first write path to the `uploads` named volume. Two infrastructure concerns are activated for the first time by this issue. **1. uploads volume must be declared and mounted** The `docker-compose.yml` must have: ```yaml volumes: uploads: # named volume — survives docker compose up --build services: app: volumes: - uploads:/app/uploads environment: UPLOAD_DIR: /app/uploads ``` If `UPLOAD_DIR` is not set, the `processAndSavePhoto()` call will write to a path that doesn't exist (or an unexpected location inside the container filesystem). The app must fail to start if `UPLOAD_DIR` is missing, not fail silently at upload time. **2. UPLOAD_DIR startup validation** Add to `src/lib/db.ts` (or app startup hook): ```typescript if (!process.env.UPLOAD_DIR) { console.error('UPLOAD_DIR environment variable is required'); process.exit(1); } ``` Without this, a misconfigured deploy silently writes photos to the container's ephemeral filesystem and loses them on the next `docker compose up --build`. **3. `.env.example` update** Add `UPLOAD_DIR=/app/uploads` to `.env.example` with a comment explaining it maps to the named volume mount path. **4. Backup coverage** The backup script already targets the `uploads` volume (`erbstuecke_uploads`). No change needed — the first photo upload confirms the backup path is correct. Verify after first test deploy with: ```bash docker compose exec app ls /app/uploads/ ``` **5. Multi-stage Dockerfile — no dev dependencies in production image** `sharp` is a production dependency — it compiles native binaries. The multi-stage Dockerfile's runner stage installs only production deps (`npm ci --omit=dev`). Confirm `sharp` is in `dependencies`, not `devDependencies`, in `package.json`. A misplaced dependency will cause "Cannot find module 'sharp'" at runtime. ### Recommendations - Add a startup validation for `UPLOAD_DIR` before the feature ships. One missing env var silently breaks every photo upload. - After first deploy with photos, verify the backup script actually includes the uploads directory: `tar -tzf /opt/backups/erbstuecke/erbstuecke-latest.tar.gz | grep uploads` should show entries. - Confirm `sharp` is in `dependencies` (not `devDependencies`) in `package.json` — this is the single most likely runtime breakage from the multi-stage Dockerfile pattern. - The `UPLOAD_DIR` path inside the container must end without a trailing slash. `path.join('/app/uploads', String(id), 'file.webp')` is correct; `path.join('/app/uploads/', ...)` also works but be consistent in `.env.example`.
Author
Owner

👤 Elicit — Requirements Engineer

Observations

Reviewing issue #10 against the approved specs (2026-05-04-erbstuecke-reservierung-design.md §5.2, views spec View 05b, system design §5) and the user stories US-ADM-001 + US-ADM-002.

Completeness check — what is well-specified:

  • Camera-first flow with capture="environment" is explicit
  • Desktop fallback ("Datei auswählen") is mentioned
  • The eight fixed KATEGORIEN are referenced by name
  • Sequential position assignment is stated
  • ?/fotoLoeschen action semantics (delete from DB + disk) are clear
  • 404 return for unknown [id] is stated
  • First photo = gallery thumbnail (position 0) with "1." badge is stated

Gaps and ambiguities I am flagging as requirements risks:

GAP-01 — Maximum photos per article (OQ-004 from design spec, still open)
The issue does not state a maximum number of photos per article. OQ-004 in the design spec recommends 10 but this was never closed. Without a limit, the thumbnail strip has no stop condition. The server action has no upper bound to enforce. This is a testable requirement that is missing.

GAP-02 — Zero-photo article: valid or invalid?
The issue says "for each valid file" — implying zero files is a degenerate case. The spec says Kategorie is required but Fotos are not mentioned as required. The inventory list view (View 05) shows a thumbnail per row — what renders when there is no photo? A placeholder? An empty grey box? This affects the gallery thumbnail on the family side too (GalerieKarte shows a 72px photo). Unspecified.

GAP-03 — Error UX on save failure
The issue states what the actions do on success. It does not state what the user sees if the server action returns fail(). Specifically: if the photo upload fails (e.g., sharp error, disk full), what message does the admin see? The sticky save bar must show a German error message. No error strings are defined in this issue.

GAP-04 — "Abbrechen" navigation target
The issue says "Abbrechen" button is in the save bar. It does not state where it navigates. For /neu, this is clearly /admin/inventar. For /[id]/bearbeiten, it could be /admin/inventar or /admin/inventar/[id] (if a detail page exists — it does not in the current spec). Confirm: both Abbrechen buttons navigate to /admin/inventar.

GAP-05 — Confirmation before discard
Neither the spec nor the issue addresses what happens if an admin taps "Abbrechen" after having taken photos but before saving. The photos in $state are lost. For a mobile camera flow, this is a significant frustration. The spec does not require an unsaved-changes guard — confirming this is intentional (acceptable for an MVP).

Recommendations

  • Close OQ-004: explicitly state "max 10 photos per article" in this issue's acceptance criteria, or document that it is deferred. The server action and UI both need this number.
  • Add an explicit acceptance criterion for the zero-photo case: "An article with no photos can be saved; the inventory list shows a grey placeholder."
  • Add German error message strings for the two primary failure modes: (1) file type rejected, (2) save failed (generic server error). These belong in the acceptance criteria so the developer doesn't invent them ad hoc.
  • Confirm and add: "Abbrechen navigates to /admin/inventar in both /neu and /bearbeiten."
  • Explicitly close the unsaved-changes guard question: "No confirmation dialog on Abbrechen — photos in progress are silently discarded (MVP decision)."

Open Decisions (omit if none)

  • Maximum photos per article (OQ-004) — The design spec left this open with a recommendation of 10. Without a closed decision, the UI has no stop condition and the server has no validation boundary. Options: (A) close at 10 per the recommendation, (B) pick a different number, (C) leave unlimited. Cost of leaving unlimited: no UI stop, potential for very large article directories. (Raised by: Elicit)
  • Zero-photo articles — Valid or invalid? If valid, the gallery thumbnail needs a placeholder. If invalid, "Speichern" must be disabled until at least one photo exists. The spec mockup shows Speichern greyed in State A, implying at least one photo is required. This should be an explicit requirement, not an implementation assumption. (Raised by: Elicit)
## 👤 Elicit — Requirements Engineer ### Observations Reviewing issue #10 against the approved specs (2026-05-04-erbstuecke-reservierung-design.md §5.2, views spec View 05b, system design §5) and the user stories US-ADM-001 + US-ADM-002. **Completeness check — what is well-specified:** - Camera-first flow with `capture="environment"` is explicit - Desktop fallback ("Datei auswählen") is mentioned - The eight fixed KATEGORIEN are referenced by name - Sequential position assignment is stated - `?/fotoLoeschen` action semantics (delete from DB + disk) are clear - 404 return for unknown `[id]` is stated - First photo = gallery thumbnail (position 0) with "1." badge is stated **Gaps and ambiguities I am flagging as requirements risks:** **GAP-01 — Maximum photos per article (OQ-004 from design spec, still open)** The issue does not state a maximum number of photos per article. OQ-004 in the design spec recommends 10 but this was never closed. Without a limit, the thumbnail strip has no stop condition. The server action has no upper bound to enforce. This is a testable requirement that is missing. **GAP-02 — Zero-photo article: valid or invalid?** The issue says "for each valid file" — implying zero files is a degenerate case. The spec says Kategorie is required but Fotos are not mentioned as required. The inventory list view (View 05) shows a thumbnail per row — what renders when there is no photo? A placeholder? An empty grey box? This affects the gallery thumbnail on the family side too (GalerieKarte shows a 72px photo). Unspecified. **GAP-03 — Error UX on save failure** The issue states what the actions do on success. It does not state what the user sees if the server action returns `fail()`. Specifically: if the photo upload fails (e.g., sharp error, disk full), what message does the admin see? The sticky save bar must show a German error message. No error strings are defined in this issue. **GAP-04 — "Abbrechen" navigation target** The issue says "Abbrechen" button is in the save bar. It does not state where it navigates. For `/neu`, this is clearly `/admin/inventar`. For `/[id]/bearbeiten`, it could be `/admin/inventar` or `/admin/inventar/[id]` (if a detail page exists — it does not in the current spec). Confirm: both Abbrechen buttons navigate to `/admin/inventar`. **GAP-05 — Confirmation before discard** Neither the spec nor the issue addresses what happens if an admin taps "Abbrechen" after having taken photos but before saving. The photos in `$state` are lost. For a mobile camera flow, this is a significant frustration. The spec does not require an unsaved-changes guard — confirming this is intentional (acceptable for an MVP). ### Recommendations - Close OQ-004: explicitly state "max 10 photos per article" in this issue's acceptance criteria, or document that it is deferred. The server action and UI both need this number. - Add an explicit acceptance criterion for the zero-photo case: "An article with no photos can be saved; the inventory list shows a grey placeholder." - Add German error message strings for the two primary failure modes: (1) file type rejected, (2) save failed (generic server error). These belong in the acceptance criteria so the developer doesn't invent them ad hoc. - Confirm and add: "Abbrechen navigates to `/admin/inventar` in both /neu and /bearbeiten." - Explicitly close the unsaved-changes guard question: "No confirmation dialog on Abbrechen — photos in progress are silently discarded (MVP decision)." ### Open Decisions _(omit if none)_ - **Maximum photos per article (OQ-004)** — The design spec left this open with a recommendation of 10. Without a closed decision, the UI has no stop condition and the server has no validation boundary. Options: (A) close at 10 per the recommendation, (B) pick a different number, (C) leave unlimited. Cost of leaving unlimited: no UI stop, potential for very large article directories. _(Raised by: Elicit)_ - **Zero-photo articles** — Valid or invalid? If valid, the gallery thumbnail needs a placeholder. If invalid, "Speichern" must be disabled until at least one photo exists. The spec mockup shows Speichern greyed in State A, implying at least one photo is required. This should be an explicit requirement, not an implementation assumption. _(Raised by: Elicit)_
Author
Owner

🗳️ Decision Queue — Action Required

5 decisions need your input before implementation starts.

Photo Upload Behaviour

  • Maximum photos per article (OQ-004) — The design spec left this open with a recommendation of 10. Without a closed decision, the thumbnail strip has no stop condition and the server action has no upper bound. Options: (A) close at 10 (recommended in spec), (B) different number, (C) unlimited (no UI stop, potentially large article directories). (Raised by: Elicit)

  • Zero-photo articles: valid or invalid? — The spec mockup (Zustand A) shows "Speichern" greyed before any photo is taken, implying at least one photo is required. The issue acceptance criteria do not state this explicitly. If photos are required, "Speichern" must be disabled in State A (needs a $derived + test). If optional, the gallery/inventory need a placeholder image for photo-less articles. This is an implementation assumption that should be a stated requirement. (Raised by: Elicit, Sara Holt)

  • capture="environment" on the file input — If the single hidden <input> always carries capture="environment", clicking "Datei auswählen" on desktop may open a camera app instead of a file picker on some browsers. Options: (A) use two separate inputs (one with capture, one without), (B) dynamically remove the capture attribute via JS before triggering the "Datei auswählen" click, (C) accept that desktop behaviour may vary (most modern desktop browsers fall back to file picker anyway). (Raised by: Felix Brandt)

Edit Form Scope

  • Photo reorder on edit (drag-to-reorder) — The system design spec describes drag-to-reorder sending a new position-array. The issue's bearbeiten acceptance criteria do not mention reorder. If deferred: admins cannot change which photo is the gallery thumbnail without deleting and re-uploading. If included: adds drag-and-drop JS complexity to this ticket. Decision needed to scope the implementation correctly. (Raised by: Markus Keller)

State A UX Clarity

  • "Speichern" disabled in State A — Directly linked to the zero-photo decision above. The spec mockup clearly shows the Speichern button greyed out when no photo exists. If this is intentional (photo required), state so explicitly. If Speichern should be enabled even without photos, the spec mockup is misleading and needs a note. This drives both the component implementation and the test strategy. (Raised by: Sara Holt, Leonie Voss)
## 🗳️ Decision Queue — Action Required _5 decisions need your input before implementation starts._ ### Photo Upload Behaviour - **Maximum photos per article (OQ-004)** — The design spec left this open with a recommendation of 10. Without a closed decision, the thumbnail strip has no stop condition and the server action has no upper bound. Options: (A) close at 10 (recommended in spec), (B) different number, (C) unlimited (no UI stop, potentially large article directories). _(Raised by: Elicit)_ - **Zero-photo articles: valid or invalid?** — The spec mockup (Zustand A) shows "Speichern" greyed before any photo is taken, implying at least one photo is required. The issue acceptance criteria do not state this explicitly. If photos are required, "Speichern" must be disabled in State A (needs a `$derived` + test). If optional, the gallery/inventory need a placeholder image for photo-less articles. This is an implementation assumption that should be a stated requirement. _(Raised by: Elicit, Sara Holt)_ - **`capture="environment"` on the file input** — If the single hidden `<input>` always carries `capture="environment"`, clicking "Datei auswählen" on desktop may open a camera app instead of a file picker on some browsers. Options: (A) use two separate inputs (one with `capture`, one without), (B) dynamically remove the `capture` attribute via JS before triggering the "Datei auswählen" click, (C) accept that desktop behaviour may vary (most modern desktop browsers fall back to file picker anyway). _(Raised by: Felix Brandt)_ ### Edit Form Scope - **Photo reorder on edit (drag-to-reorder)** — The system design spec describes drag-to-reorder sending a new `position`-array. The issue's bearbeiten acceptance criteria do not mention reorder. If deferred: admins cannot change which photo is the gallery thumbnail without deleting and re-uploading. If included: adds drag-and-drop JS complexity to this ticket. Decision needed to scope the implementation correctly. _(Raised by: Markus Keller)_ ### State A UX Clarity - **"Speichern" disabled in State A** — Directly linked to the zero-photo decision above. The spec mockup clearly shows the Speichern button greyed out when no photo exists. If this is intentional (photo required), state so explicitly. If Speichern should be enabled even without photos, the spec mockup is misleading and needs a note. This drives both the component implementation and the test strategy. _(Raised by: Sara Holt, Leonie Voss)_
Sign in to join this conversation.