Artikelbilder lokal speichern und auf der Artikelseite anzeigen

Open Food Facts liefert nur eine Bildadresse. Direkt dorthin zu verlinken hiesse:
Das Bild verschwindet, wenn OFF es austauscht, die Installation braucht Internet,
und jeder Seitenaufruf verraet OFF, welche Artikel jemand ansieht.

Das Bild wird daher einmal geholt und in einer eigenen Tabelle product_images
abgelegt - eigene Tabelle, damit Artikellisten die Blobs nicht mitziehen.
Geholt wird beim Anlegen, bei geaenderter Bildadresse und beim ersten Abruf
(so bekommen auch bestehende Artikel ihre Kopie, ohne Wanderung ueber alle
Datensaetze).

GET /products/{id}/image verlangt eine Anmeldung - aus den Bildern liesse sich
sonst ohne Konto ablesen, was im Vorrat liegt. Da ein <img src> keinen
Authorization-Header schickt, laedt die Oberflaeche das Bild ueber fetch und
zeigt es als Objekt-URL.

Angezeigt wird es rechts neben Barcode, Name und Marke. Fehlt ein Bild, rendert
die Komponente nichts und die Felder nehmen die volle Breite ein.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-23 13:35:10 +02:00
parent dc1750d23a
commit c155116b4e
8 changed files with 321 additions and 19 deletions

View File

@@ -82,6 +82,25 @@ function zeitstempel() {
+ `_${zwei(jetzt.getHours())}${zwei(jetzt.getMinutes())}`;
}
/**
* Bild mit Anmeldung laden und als lokale Objekt-URL zurückgeben.
*
* Nötig, weil ein <img src="…"> keinen Authorization-Header mitschickt. Ohne
* diesen Umweg müsste die Bildroute offen sein dann liesse sich ohne Konto
* ablesen, was im Vorrat liegt.
*
* Gibt null zurück, wenn es kein Bild gibt; das ist der Normalfall und kein
* Fehler. Wer die URL nicht mehr braucht, gibt sie mit URL.revokeObjectURL frei.
*/
export async function authorizedObjectUrl(path) {
const token = getToken();
const resp = await fetch(`${API_BASE}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!resp.ok) return null;
return URL.createObjectURL(await resp.blob());
}
// Datei mit Auth-Header laden und im Browser als Download anbieten.
export async function downloadFile(path, filename) {
const token = getToken();

View File

@@ -0,0 +1,42 @@
import { useEffect, useState } from "react";
import { authorizedObjectUrl } from "../api";
/**
* 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.
*
* 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 = "" }) {
const [url, setUrl] = useState(null);
useEffect(() => {
if (!productId) return undefined;
let abgebrochen = false;
let erzeugt = null;
authorizedObjectUrl(`/products/${productId}/image`)
.then((neu) => {
if (!neu) return;
if (abgebrochen) {
URL.revokeObjectURL(neu);
return;
}
erzeugt = neu;
setUrl(neu);
})
.catch(() => {});
return () => {
abgebrochen = true;
if (erzeugt) URL.revokeObjectURL(erzeugt);
setUrl(null);
};
}, [productId]);
if (!url) return null;
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
}

View File

@@ -5,6 +5,7 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import ProduktBild from "../components/ProduktBild";
import { useToast } from "../toast";
import { useSettings } from "../settings";
import { asTree } from "../categoryTree";
@@ -263,25 +264,34 @@ export default function ProductForm() {
<div className="grid-2">
<form className="card" onSubmit={save}>
<div className="field-inline">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
{/* Bild steht neben den Kennfeldern, nicht darueber: Der Platz rechts
von Barcode, Name und Marke ist ohnehin selten ausgenutzt. Fehlt
ein Bild, rendert die Komponente nichts und die Felder nehmen die
volle Breite ein. */}
<div className="produkt-kopf">
<div className="produkt-kopf-felder">
<div className="field-inline">
<label className="grow">
Barcode
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label>
{isAdmin && (
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
<Icon name="search" size={16} />Nachschlagen
</button>
)}
</div>
<label>
Name
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
</label>
<label>
Marke
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label>
</div>
{!isNew && <ProduktBild productId={id} alt={form.name} />}
</div>
<label>
Name
<input value={form.name} onChange={(e) => set("name", e.target.value)} required disabled={readOnly} />
</label>
<label>
Marke
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
</label>
<div className="row">
<label className="grow">
Einheit

View File

@@ -189,6 +189,25 @@ input:focus, select:focus {
input::placeholder { color: var(--muted); opacity: 0.7; }
.row { display: flex; gap: var(--sp-3); align-items: flex-start; }
.row .grow { flex: 1; }
/* Artikelbild neben den Kennfeldern. min-width:0 an der Feldspalte, sonst
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; }
.produkt-bild {
flex: 0 0 auto;
width: 132px; height: 132px;
object-fit: contain; /* Verpackungen nie beschneiden */
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: var(--sp-2);
}
/* 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; }
}
.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
rutschen daneben stehende Buttons optisch nach unten. */