Artikeldaten von Open Food Facts vergleichen und einzeln uebernehmen
Neuer Knopf unter "Nachschlagen" auf der Artikelseite. Er fragt OFF erneut und
stellt die Antwort den eigenen Daten gegenueber: Name, Marke, Packungsgroesse,
Kategorie und Bild, jeweils mit eigenem Uebernehmen-Knopf und einem
"Alle uebernehmen". Uebernommen wird nur ins Formular - gespeichert erst mit
"Speichern", damit man bis zuletzt bei den eigenen Daten bleiben kann.
Dafuer noetig: GET /products/{id}/off. /lookup taugt hier nicht, weil es bei
einem bekannten Barcode den eigenen Artikel meldet und OFF gar nicht erst
fragt. Der neue Endpunkt probiert auch die zusaetzlichen EAN-Codes durch - oft
ist nur einer davon bei OFF hinterlegt.
Damit ist das Bild unabhaengig davon nachholbar, ob ein Backup eine
Bildadresse enthielt.
Nebenbei: Der Nachschlagen-Knopf uebergab die Gruppenliste, wo die
Einheitenliste erwartet wird - die Einheit eines Vorschlags wurde deshalb nie
richtig zugeordnet.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -92,6 +92,50 @@ def get_product(
|
|||||||
return product_to_out(db, product)
|
return product_to_out(db, product)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{product_id}/off", response_model=LookupResult)
|
||||||
|
def off_vergleich(
|
||||||
|
product_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> LookupResult:
|
||||||
|
"""Open Food Facts erneut befragen – auch für einen bereits angelegten Artikel.
|
||||||
|
|
||||||
|
``/lookup`` kann das nicht: Kennt es den Barcode schon, meldet es den eigenen
|
||||||
|
Artikel und fragt OFF gar nicht erst. Zum Vergleichen braucht es aber genau
|
||||||
|
die fremden Daten.
|
||||||
|
|
||||||
|
Neben dem Haupt-Barcode werden die zusätzlichen EAN-Codes durchprobiert –
|
||||||
|
oft ist nur einer davon bei OFF hinterlegt.
|
||||||
|
"""
|
||||||
|
product = db.get(Product, product_id)
|
||||||
|
if product is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||||
|
|
||||||
|
codes = [product.barcode] if product.barcode else []
|
||||||
|
codes += [
|
||||||
|
b.code
|
||||||
|
for b in db.query(Barcode).filter(Barcode.product_id == product_id).all()
|
||||||
|
if b.code not in codes
|
||||||
|
]
|
||||||
|
if not codes:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST,
|
||||||
|
"Dieser Artikel hat keinen Barcode – ohne den kann Open Food Facts nichts finden.",
|
||||||
|
)
|
||||||
|
|
||||||
|
for code in codes:
|
||||||
|
suggestion = lookup_barcode(code)
|
||||||
|
if suggestion:
|
||||||
|
category = suggest_category(db, suggestion)
|
||||||
|
return LookupResult(
|
||||||
|
found=True,
|
||||||
|
suggestion=suggestion,
|
||||||
|
category_id=category.id if category else None,
|
||||||
|
category_name=category.name if category else None,
|
||||||
|
)
|
||||||
|
return LookupResult(found=False)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{product_id}/image")
|
@router.get("/{product_id}/image")
|
||||||
def get_product_image(
|
def get_product_image(
|
||||||
product_id: int,
|
product_id: int,
|
||||||
|
|||||||
@@ -139,6 +139,8 @@ export const api = {
|
|||||||
},
|
},
|
||||||
getProduct: (id) => request(`/products/${id}`),
|
getProduct: (id) => request(`/products/${id}`),
|
||||||
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`),
|
||||||
|
// Fragt OFF auch fuer einen bereits angelegten Artikel - zum Vergleichen.
|
||||||
|
offCompare: (id) => request(`/products/${id}/off`),
|
||||||
createProduct: (body) => request("/products", { method: "POST", body }),
|
createProduct: (body) => request("/products", { method: "POST", body }),
|
||||||
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
|
updateProduct: (id, body) => request(`/products/${id}`, { method: "PATCH", body }),
|
||||||
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
|
deleteProduct: (id) => request(`/products/${id}`, { method: "DELETE" }),
|
||||||
|
|||||||
@@ -115,6 +115,12 @@ const PATHS = {
|
|||||||
check: <path d="M20 6 9 17l-5-5" />,
|
check: <path d="M20 6 9 17l-5-5" />,
|
||||||
close: <path d="M18 6 6 18M6 6l12 12" />,
|
close: <path d="M18 6 6 18M6 6l12 12" />,
|
||||||
chevronRight: <path d="m9 18 6-6-6-6" />,
|
chevronRight: <path d="m9 18 6-6-6-6" />,
|
||||||
|
download: (
|
||||||
|
<>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<path d="M7 10l5 5 5-5M12 15V3" />
|
||||||
|
</>
|
||||||
|
),
|
||||||
box: (
|
box: (
|
||||||
<>
|
<>
|
||||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||||
|
|||||||
86
web/src/components/OffVergleich.jsx
Normal file
86
web/src/components/OffVergleich.jsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import Icon from "./Icon";
|
||||||
|
import ProduktBild from "./ProduktBild";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gegenüberstellung: eigene Daten links, Open Food Facts rechts.
|
||||||
|
*
|
||||||
|
* Nichts wird von allein übernommen. Jede Zeile hat ihren eigenen Knopf, und
|
||||||
|
* auch der schreibt nur ins Formular – gespeichert wird erst mit „Speichern“.
|
||||||
|
* So bleibt bis zuletzt die Möglichkeit, bei den eigenen Daten zu bleiben.
|
||||||
|
*
|
||||||
|
* felder: [{ schluessel, titel, eigen, fremd, anzeigeEigen?, anzeigeFremd? }]
|
||||||
|
*/
|
||||||
|
export default function OffVergleich({
|
||||||
|
felder, bildEigenId, bildFremdUrl, onUebernehmen, onAlle, onSchliessen,
|
||||||
|
}) {
|
||||||
|
const leer = (wert) => wert === null || wert === undefined || wert === "";
|
||||||
|
const abweichend = felder.filter((f) => !leer(f.fremd) && String(f.fremd) !== String(f.eigen));
|
||||||
|
const bildAbweichend = Boolean(bildFremdUrl);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="off-vergleich">
|
||||||
|
<div className="off-kopf">
|
||||||
|
<Icon name="search" size={16} />
|
||||||
|
<h3>Daten von Open Food Facts</h3>
|
||||||
|
<button type="button" className="btn sm" disabled={abweichend.length === 0}
|
||||||
|
onClick={onAlle}>
|
||||||
|
Alle übernehmen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn-icon" title="Schließen" onClick={onSchliessen}>
|
||||||
|
<Icon name="close" size={15} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{abweichend.length === 0 && !bildAbweichend ? (
|
||||||
|
<p className="muted small">Keine abweichenden Angaben – deine Daten sind aktuell.</p>
|
||||||
|
) : (
|
||||||
|
<table className="table off-tabelle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Feld</th><th>Bisher</th><th>Open Food Facts</th><th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{felder.map((f) => {
|
||||||
|
const gleich = leer(f.fremd) || String(f.fremd) === String(f.eigen);
|
||||||
|
return (
|
||||||
|
<tr key={f.schluessel} className={gleich ? "muted" : ""}>
|
||||||
|
<td data-label="Feld">{f.titel}</td>
|
||||||
|
<td data-label="Bisher">{f.anzeigeEigen ?? (leer(f.eigen) ? "–" : String(f.eigen))}</td>
|
||||||
|
<td data-label="Open Food Facts">
|
||||||
|
{f.anzeigeFremd ?? (leer(f.fremd) ? "–" : String(f.fremd))}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<button type="button" className="btn sm" disabled={gleich}
|
||||||
|
onClick={() => onUebernehmen(f.schluessel)}>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<tr className={bildAbweichend ? "" : "muted"}>
|
||||||
|
<td data-label="Feld">Bild</td>
|
||||||
|
<td data-label="Bisher"><ProduktBild productId={bildEigenId} className="off-bild" /></td>
|
||||||
|
<td data-label="Open Food Facts">
|
||||||
|
{/* Vorschau kommt hier direkt von OFF – lokal gibt es das Bild
|
||||||
|
ja gerade noch nicht. Übernommen und gespeichert, holt der
|
||||||
|
Server es einmal und liefert es danach selbst aus. */}
|
||||||
|
{bildFremdUrl
|
||||||
|
? <img className="off-bild" src={bildFremdUrl} alt="" />
|
||||||
|
: <span className="muted">–</span>}
|
||||||
|
</td>
|
||||||
|
<td className="num">
|
||||||
|
<button type="button" className="btn sm" disabled={!bildAbweichend}
|
||||||
|
onClick={() => onUebernehmen("image_url")}>
|
||||||
|
Übernehmen
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import Icon from "./Icon";
|
|||||||
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
|
* Die Objekt-URL wird beim Verlassen wieder freigegeben, sonst hält der Browser
|
||||||
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
|
* jedes angesehene Bild bis zum Neuladen der Seite im Speicher.
|
||||||
*/
|
*/
|
||||||
function useBildUrl(productId) {
|
function useBildUrl(productId, version = 0) {
|
||||||
const [url, setUrl] = useState(null);
|
const [url, setUrl] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -16,7 +16,9 @@ function useBildUrl(productId) {
|
|||||||
let abgebrochen = false;
|
let abgebrochen = false;
|
||||||
let erzeugt = null;
|
let erzeugt = null;
|
||||||
|
|
||||||
authorizedObjectUrl(`/products/${productId}/image`)
|
// ``version`` haengt einen Wert an die Adresse, damit der Browser nach dem
|
||||||
|
// Speichern nicht seine zwischengespeicherte Fassung ausliefert.
|
||||||
|
authorizedObjectUrl(`/products/${productId}/image${version ? `?v=${version}` : ""}`)
|
||||||
.then((neu) => {
|
.then((neu) => {
|
||||||
if (!neu) return;
|
if (!neu) return;
|
||||||
if (abgebrochen) {
|
if (abgebrochen) {
|
||||||
@@ -33,7 +35,7 @@ function useBildUrl(productId) {
|
|||||||
if (erzeugt) URL.revokeObjectURL(erzeugt);
|
if (erzeugt) URL.revokeObjectURL(erzeugt);
|
||||||
setUrl(null);
|
setUrl(null);
|
||||||
};
|
};
|
||||||
}, [productId]);
|
}, [productId, version]);
|
||||||
|
|
||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
@@ -44,8 +46,8 @@ function useBildUrl(productId) {
|
|||||||
* Rendert nichts, solange kein Bild vorliegt – ein leerer Rahmen neben einem
|
* Rendert nichts, solange kein Bild vorliegt – ein leerer Rahmen neben einem
|
||||||
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
|
* Artikel, der nie ein Bild bekommen wird, wäre nur Lärm.
|
||||||
*/
|
*/
|
||||||
export default function ProduktBild({ productId, alt, className = "" }) {
|
export default function ProduktBild({ productId, alt, className = "", version = 0 }) {
|
||||||
const url = useBildUrl(productId);
|
const url = useBildUrl(productId, version);
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
|
return <img className={`produkt-bild ${className}`.trim()} src={url} alt={alt || ""} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useConfirm } from "../confirm";
|
|||||||
import { useAuth } from "../auth";
|
import { useAuth } from "../auth";
|
||||||
import BarcodeList from "../components/BarcodeList";
|
import BarcodeList from "../components/BarcodeList";
|
||||||
import Icon from "../components/Icon";
|
import Icon from "../components/Icon";
|
||||||
|
import OffVergleich from "../components/OffVergleich";
|
||||||
import ProduktBild from "../components/ProduktBild";
|
import ProduktBild from "../components/ProduktBild";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useSettings } from "../settings";
|
import { useSettings } from "../settings";
|
||||||
@@ -50,6 +51,10 @@ export default function ProductForm() {
|
|||||||
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
|
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
|
||||||
const [minUnit, setMinUnit] = useState("");
|
const [minUnit, setMinUnit] = useState("");
|
||||||
const [warnDays, setWarnDays] = useState(7);
|
const [warnDays, setWarnDays] = useState(7);
|
||||||
|
// Offene Gegenüberstellung mit Open Food Facts (null = zu).
|
||||||
|
const [offDaten, setOffDaten] = useState(null);
|
||||||
|
const [offBusy, setOffBusy] = useState(false);
|
||||||
|
const [bildVersion, setBildVersion] = useState(0);
|
||||||
|
|
||||||
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
|
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
|
||||||
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
|
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
|
||||||
@@ -125,6 +130,74 @@ export default function ProductForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open Food Facts erneut befragen, obwohl der Artikel schon existiert.
|
||||||
|
* Übernommen wird nichts von allein – das entscheidet die Gegenüberstellung.
|
||||||
|
*/
|
||||||
|
async function holeOffDaten() {
|
||||||
|
setError(null);
|
||||||
|
setOffBusy(true);
|
||||||
|
try {
|
||||||
|
const res = await api.offCompare(id);
|
||||||
|
if (res.found && res.suggestion) {
|
||||||
|
setOffDaten({ ...res.suggestion, category_id: res.category_id, category_name: res.category_name });
|
||||||
|
} else {
|
||||||
|
setOffDaten(null);
|
||||||
|
toast("Open Food Facts kennt keinen der hinterlegten EAN-Codes.", "warn");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setOffBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Welche Felder die Gegenüberstellung zeigt – und woher ihr Wert kommt. */
|
||||||
|
function offFelder() {
|
||||||
|
if (!offDaten) return [];
|
||||||
|
const kategorie = categories.find((c) => String(c.id) === String(form.category_id));
|
||||||
|
return [
|
||||||
|
{ schluessel: "name", titel: "Name", eigen: form.name, fremd: offDaten.name || "" },
|
||||||
|
{ schluessel: "brand", titel: "Marke", eigen: form.brand, fremd: offDaten.brand || "" },
|
||||||
|
{
|
||||||
|
schluessel: "package_size", titel: "Packungsgröße",
|
||||||
|
eigen: form.package_size, fremd: offDaten.package_size ?? "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
schluessel: "category_id", titel: "Kategorie",
|
||||||
|
eigen: form.category_id, fremd: offDaten.category_id ?? "",
|
||||||
|
anzeigeEigen: kategorie ? kategorie.name : "–",
|
||||||
|
anzeigeFremd: offDaten.category_name || "–",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function uebernehmen(schluessel) {
|
||||||
|
if (!offDaten) return;
|
||||||
|
if (schluessel === "image_url") {
|
||||||
|
set("image_url", offDaten.image_url || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (schluessel === "package_size") {
|
||||||
|
set("package_size", offDaten.package_size != null ? String(offDaten.package_size) : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (schluessel === "category_id") {
|
||||||
|
set("category_id", offDaten.category_id ? String(offDaten.category_id) : "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
set(schluessel, offDaten[schluessel] || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function alleUebernehmen() {
|
||||||
|
offFelder().forEach((f) => {
|
||||||
|
if (f.fremd !== "" && f.fremd !== null && String(f.fremd) !== String(f.eigen)) {
|
||||||
|
uebernehmen(f.schluessel);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (offDaten?.image_url) uebernehmen("image_url");
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
@@ -215,6 +288,10 @@ export default function ProductForm() {
|
|||||||
toast("Gespeichert.");
|
toast("Gespeichert.");
|
||||||
setProduct(await api.getProduct(id));
|
setProduct(await api.getProduct(id));
|
||||||
setLots(await api.listLots(id));
|
setLots(await api.listLots(id));
|
||||||
|
// Der Server holt bei geaenderter Bildadresse ein neues Bild. Ohne
|
||||||
|
// diesen Zaehler zeigte die Seite weiter das alte aus dem Zwischenspeicher.
|
||||||
|
setBildVersion((v) => v + 1);
|
||||||
|
setOffDaten(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -276,9 +353,18 @@ export default function ProductForm() {
|
|||||||
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||||
</label>
|
</label>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
|
<div className="knopf-spalte">
|
||||||
|
<button type="button" className="btn" onClick={() => runLookup(form.barcode, units)}>
|
||||||
<Icon name="search" size={16} />Nachschlagen
|
<Icon name="search" size={16} />Nachschlagen
|
||||||
</button>
|
</button>
|
||||||
|
{!isNew && (
|
||||||
|
<button type="button" className="btn" disabled={offBusy}
|
||||||
|
onClick={holeOffDaten}>
|
||||||
|
<Icon name="download" size={16} />
|
||||||
|
{offBusy ? "Frage an…" : "Mit Open Food Facts vergleichen"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label>
|
<label>
|
||||||
@@ -290,8 +376,19 @@ export default function ProductForm() {
|
|||||||
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
|
<input value={form.brand} onChange={(e) => set("brand", e.target.value)} disabled={readOnly} />
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
{!isNew && <ProduktBild productId={id} alt={form.name} />}
|
{!isNew && <ProduktBild productId={id} alt={form.name} version={bildVersion} />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{offDaten && (
|
||||||
|
<OffVergleich
|
||||||
|
felder={offFelder()}
|
||||||
|
bildEigenId={id}
|
||||||
|
bildFremdUrl={offDaten.image_url || ""}
|
||||||
|
onUebernehmen={uebernehmen}
|
||||||
|
onAlle={alleUebernehmen}
|
||||||
|
onSchliessen={() => setOffDaten(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="row">
|
<div className="row">
|
||||||
<label className="grow">
|
<label className="grow">
|
||||||
Einheit
|
Einheit
|
||||||
|
|||||||
@@ -209,6 +209,27 @@ input::placeholder { color: var(--muted); opacity: 0.7; }
|
|||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
.produkt-kopf { flex-direction: column-reverse; align-items: center; }
|
.produkt-kopf { flex-direction: column-reverse; align-items: center; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zwei gestapelte Knoepfe neben einem Eingabefeld. */
|
||||||
|
.knopf-spalte { display: flex; flex-direction: column; gap: var(--sp-2); flex: 0 0 auto; }
|
||||||
|
.knopf-spalte .btn { justify-content: center; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Gegenueberstellung mit Open Food Facts */
|
||||||
|
.off-vergleich {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
background: var(--surface-2);
|
||||||
|
padding: var(--sp-3);
|
||||||
|
margin-bottom: var(--sp-4);
|
||||||
|
}
|
||||||
|
.off-kopf { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-3); }
|
||||||
|
.off-kopf h3 { font-size: 0.95rem; margin: 0; flex: 1; }
|
||||||
|
.off-tabelle { background: var(--surface); }
|
||||||
|
/* Klein genug, dass die Zeile nicht auseinandergeht, gross genug zum Erkennen. */
|
||||||
|
.off-bild {
|
||||||
|
width: 56px; aspect-ratio: 3 / 4; object-fit: contain;
|
||||||
|
background: var(--surface-2); border-radius: 4px; display: block;
|
||||||
|
}
|
||||||
.field-inline { display: flex; gap: var(--sp-2); align-items: flex-end; margin-bottom: var(--sp-4); }
|
.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
|
/* Labels in einer Inline-Reihe duerfen keinen eigenen Abstand haben, sonst
|
||||||
rutschen daneben stehende Buttons optisch nach unten. */
|
rutschen daneben stehende Buttons optisch nach unten. */
|
||||||
|
|||||||
Reference in New Issue
Block a user