From 3763f2f1143e3e12ead39bb3424e17ebfe3db113 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Thu, 23 Jul 2026 13:48:20 +0200 Subject: [PATCH] Artikelbild: nur noch an einer Stelle, hochkant, und beim Import beruecksichtigt Das alte Bild in der Chargen-Karte entfaellt - seit dem neuen Bild neben den Kennfeldern stand es doppelt auf der Seite. Der Rahmen ist jetzt hochkant (3:4) statt quadratisch. Lebensmittelverpackungen sind fast immer hoeher als breit; im Quadrat blieben links und rechts leere Streifen. Beim Import wurde image_url komplett verworfen, obwohl das JSON-Backup sie enthaelt - eingelesene Artikel blieben deshalb dauerhaft ohne Bild. Die Adresse wird jetzt uebernommen (bei bestehenden Artikeln nur ergaenzend, nie ueberschreibend); die lokale Kopie holt der Bildabruf beim ersten Ansehen. Bewusst nicht waehrend des Imports: Ein Backup mit 200 Artikeln wuerde sonst 200 Downloads in einer Anfrage abarbeiten. Ausserdem holen jetzt auch Artikelliste, Ein- und Auslagern ihr Vorschaubild aus der eigenen Datenbank statt direkt von Open Food Facts. Einzige Ausnahme bleibt der OFF-Vorschlag beim Scannen: Dort existiert der Artikel noch nicht. Co-Authored-By: Claude Opus 4.8 --- backend/app/routers/transfer.py | 17 +++++++++++++++ web/src/components/ProduktBild.jsx | 33 +++++++++++++++++++++++++----- web/src/pages/CheckIn.jsx | 8 +++++--- web/src/pages/CheckOut.jsx | 5 ++--- web/src/pages/ProductForm.jsx | 4 +--- web/src/pages/Products.jsx | 5 ++--- web/src/styles.css | 8 ++++---- 7 files changed, 59 insertions(+), 21 deletions(-) diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 9f246fd..38df096 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -339,6 +339,17 @@ def _get_or_create_group(db: Session, name: str | None) -> Group | None: return group +def _bildadresse_ergaenzen(product: Product, row: dict) -> None: + """Fehlende Bildadresse aus dem Backup nachtragen – vorhandene bleibt. + + Nur ergaenzend: Ein Import soll Daten hinzufuegen, nicht stillschweigend + etwas ueberschreiben, das jemand von Hand gesetzt hat. + """ + adresse = (row.get("image_url") or "").strip() + if adresse and not product.image_url: + product.image_url = adresse + + def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Product: """Sucht per Barcode, sonst per Name; legt das Produkt sonst an.""" barcode = (row.get("barcode") or "").strip() or None @@ -346,10 +357,12 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc if barcode: product = db.query(Product).filter(Product.barcode == barcode).first() if product: + _bildadresse_ergaenzen(product, row) return product if name: product = db.query(Product).filter(Product.name == name).first() if product: + _bildadresse_ergaenzen(product, row) return product if not name: raise ValueError("Produktname fehlt") @@ -363,6 +376,10 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc barcode=barcode, name=name, brand=(row.get("marke") or "").strip() or None, + # Die Bildadresse stand zwar immer im JSON-Backup, wurde beim Einlesen + # aber verworfen - eingelesene Artikel blieben deshalb dauerhaft ohne + # Bild. Die lokale Kopie holt sich der Bildabruf beim ersten Ansehen. + image_url=(row.get("image_url") or "").strip() or None, base_unit=BASE_OF_KIND[unit.kind], display_unit_id=unit.id, package_size=_num(row.get("packungsgroesse")), diff --git a/web/src/components/ProduktBild.jsx b/web/src/components/ProduktBild.jsx index 52c5e57..001b935 100644 --- a/web/src/components/ProduktBild.jsx +++ b/web/src/components/ProduktBild.jsx @@ -1,16 +1,14 @@ import { useEffect, useState } from "react"; import { authorizedObjectUrl } from "../api"; +import Icon from "./Icon"; /** - * Artikelbild aus der eigenen Datenbank. - * - * Rendert nichts, solange kein Bild vorliegt – ein Platzhalter für einen - * Artikel, der nie ein Bild bekommen wird, wäre nur Lärm. + * Lädt das Artikelbild aus der eigenen Datenbank. * * Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser * jedes angesehene Bild bis zum Neuladen der Seite im Speicher. */ -export default function ProduktBild({ productId, alt, className = "" }) { +function useBildUrl(productId) { const [url, setUrl] = useState(null); useEffect(() => { @@ -37,6 +35,31 @@ export default function ProduktBild({ productId, alt, className = "" }) { }; }, [productId]); + return url; +} + +/** + * Grosses Artikelbild für die Artikelseite. + * + * Rendert nichts, solange kein Bild vorliegt – ein leerer Rahmen neben einem + * Artikel, der nie ein Bild bekommen wird, wäre nur Lärm. + */ +export default function ProduktBild({ productId, alt, className = "" }) { + const url = useBildUrl(productId); if (!url) return null; return {alt; } + +/** + * Kleines Vorschaubild für Listen und Auswahlen. + * + * Hier gibt es sehr wohl einen Platzhalter: In einer Tabellenspalte müssen alle + * Zeilen gleich hoch bleiben, sonst springt die Liste. + */ +export function ProduktThumb({ productId, alt }) { + const url = useBildUrl(productId); + if (!url) { + return ; + } + return {alt; +} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 3cc6114..61ad06d 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; +import { ProduktThumb } from "../components/ProduktBild"; import { useToast } from "../toast"; import { suggestionToProduct } from "../offUtils"; import { asTree } from "../categoryTree"; @@ -277,6 +278,9 @@ export default function CheckIn() { {suggestion && (
+ {/* Hier ausnahmsweise direkt von Open Food Facts: Der Artikel + existiert noch gar nicht, es gibt also nichts Lokales. Ab + dem Anlegen kommt das Bild aus der eigenen Datenbank. */} {suggestion.image_url ? : } @@ -354,9 +358,7 @@ export default function CheckIn() { {product && (
- {product.image_url - ? - : } +
{product.name}
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
diff --git a/web/src/pages/CheckOut.jsx b/web/src/pages/CheckOut.jsx index cdbad76..066e7fa 100644 --- a/web/src/pages/CheckOut.jsx +++ b/web/src/pages/CheckOut.jsx @@ -1,6 +1,7 @@ import { useEffect, useState } from "react"; import { api } from "../api"; import Icon from "../components/Icon"; +import { ProduktThumb } from "../components/ProduktBild"; import { useToast } from "../toast"; import { useSettings } from "../settings"; import { buildUnitOptions, fmt, isExpired, unitShort } from "../units"; @@ -143,9 +144,7 @@ export default function CheckOut() { {product && (
- {product.image_url - ? - : } +
{product.name}
diff --git a/web/src/pages/ProductForm.jsx b/web/src/pages/ProductForm.jsx index efe1911..28947ed 100644 --- a/web/src/pages/ProductForm.jsx +++ b/web/src/pages/ProductForm.jsx @@ -400,7 +400,6 @@ export default function ProductForm() { baseShort={baseShort} warnDays={warnDays} isAdmin={isAdmin} - imageUrl={form.image_url} onChanged={async () => { setLots(await api.listLots(id)); setProduct(await api.getProduct(id)); @@ -438,7 +437,7 @@ export default function ProductForm() { } /** Chargen-Karte mit Bearbeiten/Löschen. Bearbeitet wird in der Produkteinheit. */ -function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) { +function LotsCard({ product, lots, baseShort, warnDays, isAdmin, onChanged, onError }) { const confirm = useConfirm(); const toast = useToast(); const { formatBestBefore } = useSettings(); @@ -532,7 +531,6 @@ function LotsCard({ product, lots, baseShort, warnDays, isAdmin, imageUrl, onCha Dieses Produkt hat abgelaufene Chargen im Bestand.
)} - {imageUrl && }
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index 84856a8..d51a59c 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -3,6 +3,7 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; +import { ProduktThumb } from "../components/ProduktBild"; import { fmt, unitShort } from "../units"; import { asTree } from "../categoryTree"; @@ -95,9 +96,7 @@ export default function Products() { return (
- {p.image_url - ? - : } + diff --git a/web/src/styles.css b/web/src/styles.css index 38c4cdd..996c74b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -194,9 +194,11 @@ input::placeholder { color: var(--muted); opacity: 0.7; } drueckt ein langer Produktname die Eingaben aus der Karte heraus. */ .produkt-kopf { display: flex; gap: var(--sp-4); align-items: flex-start; } .produkt-kopf-felder { flex: 1 1 auto; min-width: 0; } +/* Hochkant: Lebensmittelverpackungen sind fast immer hoeher als breit - + in einem quadratischen Rahmen blieben links und rechts leere Streifen. */ .produkt-bild { flex: 0 0 auto; - width: 132px; height: 132px; + width: 132px; aspect-ratio: 3 / 4; object-fit: contain; /* Verpackungen nie beschneiden */ background: var(--surface-2); border: 1px solid var(--border); @@ -205,8 +207,7 @@ input::placeholder { color: var(--muted); opacity: 0.7; } } /* Auf schmalen Fenstern haben Bild und Felder nebeneinander keinen Platz mehr. */ @media (max-width: 560px) { - .produkt-kopf { flex-direction: column-reverse; } - .produkt-bild { width: 100%; height: 160px; } + .produkt-kopf { flex-direction: column-reverse; align-items: center; } } .field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); } /* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst @@ -307,7 +308,6 @@ td select { width: auto; min-width: 0; max-width: 100%; } border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--surface-2); } .thumb-fallback { display: grid; place-items: center; color: var(--muted); } -.product-img { max-width: 150px; border-radius: var(--radius); border: 1px solid var(--border); margin-bottom: var(--sp-3); } /* ---------- Badges / pills ---------- */ .badge {