From 0f875f347e2454bae3193bb156fe0a0e8f0d10b6 Mon Sep 17 00:00:00 2001 From: seb Date: Sun, 27 Sep 2026 16:19:08 +0200 Subject: [PATCH] u --- src/api/index.js | 126 ++++ src/automats/brandAutomat.js | 21 + src/components/CompanySetupDialog.jsx | 300 ++++++++- src/components/article/ThumbHashImage.jsx | 39 +- .../article/boxes/ArticleViewBrand.jsx | 53 +- .../brand/BrandImageSearchDialog.jsx | 623 ++++++++++++++++++ src/components/brand/BrandLabel.jsx | 53 ++ src/components/brand/BrandThumb.jsx | 112 ++++ src/components/brand/index.js | 3 + .../manifest/ArticleSearchDialog.jsx | 32 +- src/components/manifest/ManifestFormLine.jsx | 14 + src/components/settings/SettingsKeysPanel.jsx | 2 + src/components/tiles/ArticleTile.jsx | 30 + src/pages/artikel/index.jsx | 18 + src/pages/artikel/marken.jsx | 50 +- src/utils/cropLogo.js | 339 ++++++++++ 16 files changed, 1768 insertions(+), 47 deletions(-) create mode 100644 src/components/brand/BrandImageSearchDialog.jsx create mode 100644 src/components/brand/BrandLabel.jsx create mode 100644 src/components/brand/BrandThumb.jsx create mode 100644 src/components/brand/index.js create mode 100644 src/utils/cropLogo.js diff --git a/src/api/index.js b/src/api/index.js index 0da6f97..aed7755 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -589,6 +589,27 @@ export const brand = { delete(id) { return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' }); }, + + /** + * POST /api/brand/:id/image + * Accepts full-size AVIF body (Content-Type: image/avif). + * → 201 { brand: { id, fullHash, thumbW, thumbH, thumbHash, pics } } + */ + addImage(brandId, file) { + return httpBinary(`/api/brand/${brandId}/image`, { + method: 'POST', + contentType: 'image/avif', + body: file, + }); + }, + + /** + * DELETE /api/brand/:id/image + * → 200 { brand: { id, thumbW: null, thumbH: null, thumbHash: null, pics: [] } } + */ + deleteImage(brandId) { + return http(`/api/brand/${brandId}/image`, { method: 'DELETE' }); + }, }; /** @@ -842,6 +863,111 @@ export const delivery = { }, }; +/** + * Serper Google Images search and WebP thumbnail streaming. + */ +export const serper = { + /** + * POST /api/serper/images + * Body: { q, gl, hl, num, preferSource } + * Streams SSE events: images, thumb, done, error + * + * @param {object} params + * @param {string} params.q - Search query + * @param {string} [params.gl='de'] - Country code + * @param {string} [params.hl='de'] - Language code + * @param {number} [params.num=100] - Result count (1-100) + * @param {boolean} [params.preferSource=false] - Prefer high-res source image + * @param {object} [options] + * @param {AbortSignal} [options.signal] - Abort controller signal + * @returns {AsyncGenerator<{event: string, data: any}>} + */ + async *searchImages( + { q, gl = 'de', hl = 'de', num = 100, preferSource = false } = {}, + { signal } = {}, + ) { + const res = await fetch('/api/serper/images', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + credentials: 'include', + body: JSON.stringify({ q, gl, hl, num, preferSource }), + signal, + }); + if (!res.ok) { + let message = res.statusText; + try { + const data = await res.json(); + message = data.error || data.message || message; + } catch { + /* ignore */ + } + const err = new Error(message || `Request failed (${res.status})`); + err.status = res.status; + throw err; + } + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + let currentEvent = 'message'; + + const onAbort = () => { + try { + reader.cancel(); + } catch { + /* ignore */ + } + }; + if (signal) { + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener('abort', onAbort, { once: true }); + } + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buf += decoder.decode(value, { stream: true }); + const lines = buf.split('\n'); + buf = lines.pop(); + for (const line of lines) { + if (line.startsWith('event:')) { + currentEvent = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const raw = line.slice(5).trim(); + let data; + try { + data = JSON.parse(raw); + } catch { + data = raw; + } + yield { event: currentEvent, data }; + currentEvent = 'message'; + } + } + } + } catch (err) { + if (signal?.aborted || err.name === 'AbortError') { + return; + } + throw err; + } finally { + if (signal) { + signal.removeEventListener('abort', onAbort); + } + try { + reader.releaseLock(); + } catch { + /* ignore */ + } + } + }, +}; + /** * Append-only stock and payment ledger. * Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at } diff --git a/src/automats/brandAutomat.js b/src/automats/brandAutomat.js index b910b57..a62263b 100644 --- a/src/automats/brandAutomat.js +++ b/src/automats/brandAutomat.js @@ -64,6 +64,27 @@ const actions = { brandAutomat.setState({ brands }); }, + /** + * Add / update brand image. + * @param {number} id + * @param {Blob|File} file + */ + async addImage(id, file) { + const res = await brandApi.addImage(id, file); + await brandAutomat.actions.load(); + return res.brand; + }, + + /** + * Delete brand image. + * @param {number} id + */ + async deleteImage(id) { + const res = await brandApi.deleteImage(id); + await brandAutomat.actions.load(); + return res.brand; + }, + /** Reset to idle state (called on logout / company switch). */ reset() { brandAutomat.setState(initialState); diff --git a/src/components/CompanySetupDialog.jsx b/src/components/CompanySetupDialog.jsx index 57c9e6a..d28932e 100644 --- a/src/components/CompanySetupDialog.jsx +++ b/src/components/CompanySetupDialog.jsx @@ -2,6 +2,7 @@ import { Component } from 'react'; import Alert from '@mui/material/Alert'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; +import Chip from '@mui/material/Chip'; import CircularProgress from '@mui/material/CircularProgress'; import Dialog from '@mui/material/Dialog'; import DialogContent from '@mui/material/DialogContent'; @@ -13,14 +14,26 @@ import InputAdornment from '@mui/material/InputAdornment'; import List from '@mui/material/List'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; +import Paper from '@mui/material/Paper'; import Stack from '@mui/material/Stack'; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; import TextField from '@mui/material/TextField'; import ToggleButton from '@mui/material/ToggleButton'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import Typography from '@mui/material/Typography'; import BusinessIcon from '@mui/icons-material/Business'; +import Inventory2Icon from '@mui/icons-material/Inventory2'; +import PeopleIcon from '@mui/icons-material/People'; +import LocalShippingIcon from '@mui/icons-material/LocalShipping'; +import TuneIcon from '@mui/icons-material/Tune'; +import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome'; import BackendError from './BackendError'; -import { company } from '../api'; +import { company, number as numberApi } from '../api'; import { StorageManagerContext } from '../api/storageManagerContext'; import authAutomat from '../automats/authAutomat'; import { @@ -30,6 +43,67 @@ import { } from '../utils/companyForm'; import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; +const NUMBER_SCHEMES = [ + { type: 'sku', label: 'Artikel-SKU', icon: Inventory2Icon }, + { type: 'entity', label: 'Kundennummer', icon: PeopleIcon }, + { type: 'delivery', label: 'Lieferschein', icon: LocalShippingIcon }, + { type: 'adjustment', label: 'Bestandskorrektur', icon: TuneIcon }, +]; + +const PRESETS = [ + { + id: 'standard', + label: 'Standard', + hint: 'Klassische Kürzel (ART-, KD-, LS-, KORR-)', + values: { + sku: { prefix: 'ART-', padding: 6, next: 1 }, + entity: { prefix: 'KD-', padding: 6, next: 1001 }, + delivery: { prefix: 'LS-', padding: 6, next: 1 }, + adjustment: { prefix: 'KORR-', padding: 5, next: 1 }, + }, + }, + { + id: 'numeric', + label: 'Rein numerisch', + hint: 'Ohne Präfix, mit Nummernblöcken (10001, 20001…)', + values: { + sku: { prefix: '', padding: 5, next: 10001 }, + entity: { prefix: '', padding: 5, next: 20001 }, + delivery: { prefix: '', padding: 5, next: 30001 }, + adjustment: { prefix: '', padding: 5, next: 90001 }, + }, + }, + { + id: 'compact', + label: 'Kurzkürzel', + hint: 'Einbuchstabige Präfixe (A-, K-, L-, B-)', + values: { + sku: { prefix: 'A-', padding: 5, next: 1 }, + entity: { prefix: 'K-', padding: 5, next: 1001 }, + delivery: { prefix: 'L-', padding: 5, next: 1 }, + adjustment: { prefix: 'B-', padding: 5, next: 1 }, + }, + }, + { + id: 'international', + label: 'International', + hint: 'Englische Kürzel (SKU-, C-, DEL-, ADJ-)', + values: { + sku: { prefix: 'SKU-', padding: 5, next: 1 }, + entity: { prefix: 'C-', padding: 5, next: 1001 }, + delivery: { prefix: 'DEL-', padding: 5, next: 1 }, + adjustment: { prefix: 'ADJ-', padding: 4, next: 1 }, + }, + }, +]; + +function formatNumberPreview(prefix, padding, next, offset = 0) { + const pad = Math.max(0, Number(padding) || 0); + const num = Math.max(0, (Number(next) || 1) + offset); + const numStr = pad > 0 ? String(num).padStart(pad, '0') : String(num); + return `${prefix ?? ''}${numStr}`; +} + class CompanySetupDialog extends Component { static contextType = StorageManagerContext; @@ -44,11 +118,15 @@ class CompanySetupDialog extends Component { iso639: defaultIso639(), taxrate: '19', reducedtaxrate: '7', + selectedPreset: 'standard', + numbers: { ...PRESETS[0].values }, }; this.handleSelect = this.handleSelect.bind(this); this.handleCreate = this.handleCreate.bind(this); this.handleLogout = this.handleLogout.bind(this); this.handleClose = this.handleClose.bind(this); + this.applyPreset = this.applyPreset.bind(this); + this.handleNumberChange = this.handleNumberChange.bind(this); } componentDidMount() { @@ -79,9 +157,31 @@ class CompanySetupDialog extends Component { } } + applyPreset(presetId) { + const preset = PRESETS.find((p) => p.id === presetId); + if (!preset) return; + this.setState({ + selectedPreset: presetId, + numbers: { ...preset.values }, + }); + } + + handleNumberChange(type, field, val) { + this.setState((prev) => ({ + selectedPreset: 'custom', + numbers: { + ...prev.numbers, + [type]: { + ...prev.numbers[type], + [field]: val, + }, + }, + })); + } + async handleCreate(event) { event.preventDefault(); - const { name, iso639, taxrate, reducedtaxrate } = this.state; + const { name, iso639, taxrate, reducedtaxrate, numbers } = this.state; const parsedTaxrate = parseTaxRate(taxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const canCreate = @@ -103,8 +203,29 @@ class CompanySetupDialog extends Component { taxrate: parsedTaxrate, reducedtaxrate: parsedReducedtaxrate, }); + + // Apply initial number sequences if set + if (numbers) { + try { + const listRes = await numberApi.list(); + const serverNumbers = listRes?.numbers ?? []; + for (const serverRow of serverNumbers) { + const userConfig = numbers[serverRow.type]; + if (userConfig) { + const prefix = String(userConfig.prefix ?? ''); + const padding = Math.max(0, Number(userConfig.padding) || 0); + const next = Math.max(1, Number(userConfig.next) || 1); + await numberApi.update(serverRow.id, { prefix, padding, next }); + } + } + } catch (numErr) { + console.warn('Initial number sequences could not be saved:', numErr); + } + } + await this.context.updateAuthMe(); await this.context.updateCompany(); + await this.context.updateNumber?.(); } catch (err) { this.setState({ error: backendErrorFrom(err) }); } finally { @@ -126,8 +247,18 @@ class CompanySetupDialog extends Component { } render() { - const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } = - this.state; + const { + companies, + loading, + busy, + error, + name, + iso639, + taxrate, + reducedtaxrate, + selectedPreset, + numbers, + } = this.state; const hasCompanies = companies.length > 0; const parsedTaxrate = parseTaxRate(taxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); @@ -138,20 +269,25 @@ class CompanySetupDialog extends Component { parsedReducedtaxrate != null; return ( - - + + {loading ? 'Unternehmen' : hasCompanies ? 'Unternehmen auswählen' - : 'Unternehmen anlegen'} + : 'Willkommen bei Dash'} {!loading ? ( - + {hasCompanies ? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.' - : 'Legen Sie Ihr Unternehmen an, um fortzufahren.'} + : 'Legen Sie Ihr erstes Unternehmen an und wählen Sie das Format für Ihre Nummernkreise.'} ) : null} @@ -191,7 +327,7 @@ class CompanySetupDialog extends Component { {hasCompanies ? oder : null} - + {hasCompanies ? ( Neues Unternehmen anlegen ) : null} @@ -200,7 +336,7 @@ class CompanySetupDialog extends Component { fullWidth required size="small" - label="Name" + label="Unternehmensname" value={name} disabled={busy} onChange={(e) => this.setState({ name: e.target.value })} @@ -225,7 +361,7 @@ class CompanySetupDialog extends Component { - + - + ); + })} + {selectedPreset === 'custom' ? ( + + ) : null} + + + + + + + Bereich + Präfix + Stellen + Startnr. + Vorschau + + + + {NUMBER_SCHEMES.map(({ type, label, icon: Icon }) => { + const cfg = numbers[type] || { prefix: '', padding: 5, next: 1 }; + return ( + + + + + + {label} + + + + + this.handleNumberChange(type, 'prefix', e.target.value)} + placeholder="z.B. ART-" + sx={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }} + /> + + + this.handleNumberChange(type, 'padding', e.target.value)} + /> + + + this.handleNumberChange(type, 'next', e.target.value)} + /> + + + + + + → + + + + + + ); + })} + +
+
+
+ + @@ -287,3 +558,4 @@ class CompanySetupDialog extends Component { } export default CompanySetupDialog; + diff --git a/src/components/article/ThumbHashImage.jsx b/src/components/article/ThumbHashImage.jsx index 3dbd25c..4214bee 100644 --- a/src/components/article/ThumbHashImage.jsx +++ b/src/components/article/ThumbHashImage.jsx @@ -2,19 +2,34 @@ import { useEffect, useRef } from 'react'; import Box from '@mui/material/Box'; import { thumbHashToRGBA } from 'thumbhash'; -function decodeThumbHash(thumbHash) { - const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0)); - return thumbHashToRGBA(bytes); +export function decodeThumbHash(thumbHash) { + if (!thumbHash || typeof thumbHash !== 'string') return null; + try { + const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0)); + return thumbHashToRGBA(bytes); + } catch (err) { + console.warn('[ThumbHashImage] decode failed:', err); + return null; + } } -function paintThumbHash(canvas, thumbHash) { +export function getThumbHashDimensions(thumbHash) { + const decoded = decodeThumbHash(thumbHash); + if (!decoded) return null; + return { w: decoded.w, h: decoded.h }; +} + +function paintThumbHash(canvas, thumbHash, explicitWidth, explicitHeight) { const parent = canvas.parentElement; - if (!parent) return; + const rect = parent ? parent.getBoundingClientRect() : null; + const width = explicitWidth || rect?.width; + const height = explicitHeight || rect?.height; + if (!width || !height) return; - const { width, height } = parent.getBoundingClientRect(); - if (width === 0 || height === 0) return; + const decoded = decodeThumbHash(thumbHash); + if (!decoded) return; + const { w, h, rgba } = decoded; - const { w, h, rgba } = decodeThumbHash(thumbHash); const src = document.createElement('canvas'); src.width = w; src.height = h; @@ -34,24 +49,24 @@ function paintThumbHash(canvas, thumbHash) { ctx.drawImage(src, 0, 0, canvas.width, canvas.height); } -export default function ThumbHashImage({ thumbHash, sx }) { +export default function ThumbHashImage({ thumbHash, width, height, sx }) { const canvasRef = useRef(null); useEffect(() => { const canvas = canvasRef.current; if (!canvas || !thumbHash) return; - paintThumbHash(canvas, thumbHash); + paintThumbHash(canvas, thumbHash, width, height); const parent = canvas.parentElement; if (!parent || typeof ResizeObserver === 'undefined') return undefined; const observer = new ResizeObserver(() => { - paintThumbHash(canvas, thumbHash); + paintThumbHash(canvas, thumbHash, width, height); }); observer.observe(parent); return () => observer.disconnect(); - }, [thumbHash]); + }, [thumbHash, width, height]); return ( String(b.name ?? '').toLowerCase() === trimmed, + ); + return ( { onChange({ name: option ? String(option.name ?? '') : '' }); }} + renderOption={(props, option) => { + const { key, ...optionProps } = props; + const name = typeof option === 'string' ? option : String(option.name ?? ''); + return ( + + + {name} + + ); + }} renderInput={(params) => ( + + + ) : ( + params.slotProps?.input?.startAdornment + ), + }, inputLabel: { ...params.slotProps?.inputLabel, shrink: true, @@ -152,8 +190,19 @@ export default class ArticleViewBrand extends PureComponent { onSave={this.handleSave} onError={notifyError} isDirty={brandDraftDirty} + display={ + + {value.name ? ( + + ) : null} + + } canSave={brandDraftValid} - display={{value.name || null}} edit={({ draft, onChange, inputRef }) => ( { + this.initSearch(); + }, + ); + } else if (!this.props.open && prevProps.open) { + this.abortCurrentSearch(); + } + } + + componentWillUnmount() { + this.abortCurrentSearch(); + } + + abortCurrentSearch() { + if (this.abortController) { + this.abortController.abort(); + this.abortController = null; + } + } + + initSearch = () => { + const { query } = this.state; + if (!query.trim()) return; + + this.abortCurrentSearch(); + const abortController = new AbortController(); + this.abortController = abortController; + + this.setState({ + status: 'loading', + error: null, + images: [], + loadedThumbs: 0, + }); + + void (async () => { + try { + const stream = serper.searchImages( + { q: query.trim(), gl: 'de', hl: 'de', num: 100 }, + { signal: abortController.signal }, + ); + + for await (const chunk of stream) { + if (abortController.signal.aborted) return; + const { event, data } = chunk; + + if (event === 'images') { + const rawImages = Array.isArray(data?.images) ? data.images : []; + const initialList = rawImages.map((img) => ({ + id: img.id, + url: img.url, + width: img.width, + height: img.height, + dataUrl: null, + failed: false, + })); + this.setState({ + images: initialList, + status: initialList.length === 0 ? 'done' : 'streaming', + }); + } else if (event === 'thumb') { + const { id, dataUrl } = data || {}; + this.setState((prev) => { + const nextImages = prev.images.map((img) => { + if (img.id === id) { + return { + ...img, + dataUrl: dataUrl || null, + failed: !dataUrl, + }; + } + return img; + }); + return { + images: nextImages, + loadedThumbs: prev.loadedThumbs + 1, + }; + }); + } else if (event === 'done') { + this.setState({ status: 'done' }); + } else if (event === 'error') { + this.setState({ + status: 'error', + error: data?.error || 'Fehler bei der Bildersuche', + }); + } + } + } catch (err) { + if (abortController.signal.aborted) return; + this.setState({ + status: 'error', + error: err?.message || String(err), + }); + } + })(); + }; + + handleQueryKeyDown = (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + this.initSearch(); + } + }; + + handleCopyUrl = (url, e) => { + e?.stopPropagation(); + if (!url) return; + navigator.clipboard?.writeText(url); + enqueueSnackbar('Bild-URL in Zwischenablage kopiert.', { variant: 'info' }); + }; + + handleOpenUrl = (url, e) => { + e?.stopPropagation(); + if (!url) return; + window.open(url, '_blank', 'noopener,noreferrer'); + }; + + handleSelectImage = async (img) => { + const { brand, onClose } = this.props; + if (!brand?.id) { + enqueueSnackbar('Keine Marke ausgewählt.', { variant: 'error' }); + return; + } + if (this.state.processingId) return; + + this.setState({ + processingId: img.id, + processingStep: 'Herunterladen & Freistellen...', + }); + + try { + // 1. Download, auto white & transparency crop (removing all margins), encode to AVIF + const avifFile = await downloadAndPrepareLogoAvif(img.url, img.dataUrl); + + this.setState({ processingStep: 'Logo wird gespeichert...' }); + + // 2. Upload as brandpic and update brandAutomat + await brandAutomat.actions.addImage(brand.id, avifFile); + + enqueueSnackbar(`Markenlogo für „${brand.name}“ erfolgreich gespeichert.`, { + variant: 'success', + }); + + if (onClose) { + onClose(); + } + } catch (err) { + console.error('Failed to process and upload brand logo:', err); + enqueueSnackbar(`Fehler beim Freistellen oder Speichern: ${err?.message || err}`, { + variant: 'error', + }); + this.setState({ processingId: null, processingStep: '' }); + } + }; + + render() { + const { open, onClose, brand } = this.props; + const { query, status, error, images, loadedThumbs, processingId, processingStep } = this.state; + const isSearching = status === 'loading' || status === 'streaming'; + const isSerperKeyError = + error && error.toLowerCase().includes('keys.serper'); + + return ( + + + + + + Logo-Suche: {brand?.name || 'Marke'} + + + + + + + + + {/* Search bar */} + { + e.preventDefault(); + this.initSearch(); + }} + sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }} + > + this.setState({ query: e.target.value })} + onKeyDown={this.handleQueryKeyDown} + placeholder="z. B. Nike logo png" + slotProps={{ + input: { + startAdornment: ( + + + + ), + }, + }} + /> + + + + {/* User helper tip */} + {images.length > 0 && !error && ( + + + + Klicken Sie auf ein Logo: Es wird heruntergeladen, automatisch freigestellt (Weiß- & Transparenz-Ränder entfernt), in AVIF konvertiert und als Markenbild hinterlegt. + + + )} + + {/* Progress / Status banner */} + {status === 'loading' && } + + {status === 'streaming' && ( + + + + Thumbnails werden gestreamt... + + + {loadedThumbs} / {images.length} + + + 0 + ? Math.round((loadedThumbs / images.length) * 100) + : 0 + } + sx={{ borderRadius: 1 }} + /> + + )} + + {/* Error banner */} + {error && ( + + + {error} + + {isSerperKeyError && ( + + Hinweis: Sie können Ihren Serper API-Key im Hauptmenü unter{' '} + Firmeneinstellungen → Einstellungen (Serper API-Key) hinterlegen. + + )} + + )} + + {/* Empty state when done */} + {status === 'done' && images.length === 0 && !error && ( + + + + Keine Bilder zu „{query}“ gefunden. + + + )} + + {/* Image Grid */} + {images.length > 0 && ( + + {images.map((img) => { + const isThisProcessing = processingId === img.id; + const isOtherProcessing = Boolean(processingId) && !isThisProcessing; + + return ( + this.handleSelectImage(img)} + sx={{ + position: 'relative', + aspectRatio: '1 / 1', + borderRadius: 1.5, + overflow: 'hidden', + border: '1px solid', + borderColor: isThisProcessing ? 'primary.main' : 'divider', + bgcolor: '#fff', + backgroundImage: + 'linear-gradient(45deg, #f0f0f0 25%, transparent 25%), linear-gradient(-45deg, #f0f0f0 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f0f0f0 75%), linear-gradient(-45deg, transparent 75%, #f0f0f0 75%)', + backgroundSize: '16px 16px', + backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0px', + cursor: processingId ? 'default' : 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + opacity: isOtherProcessing ? 0.45 : 1, + pointerEvents: isOtherProcessing ? 'none' : 'auto', + transition: 'transform 0.15s, box-shadow 0.15s, border-color 0.15s, opacity 0.15s', + '&:hover': { + transform: processingId ? 'none' : 'scale(1.02)', + boxShadow: processingId ? 0 : 3, + borderColor: 'primary.main', + '& .image-overlay-actions': { + opacity: 1, + }, + '& .image-overlay-select': { + opacity: 1, + transform: 'translateY(0)', + }, + }, + }} + > + {img.dataUrl ? ( + + ) : img.failed ? ( + + + + ) : ( + + )} + + {/* Dimensions badge */} + {img.width && img.height ? ( + + ) : null} + + {/* Quick selection hover pill */} + {!processingId && ( + + + + Als Logo wählen + + + )} + + {/* Processing overlay for selected card */} + {isThisProcessing && ( + + + + {processingStep || 'Wird freigestellt...'} + + + )} + + {/* Hover action overlay (open / copy url) */} + {img.url && !processingId && ( + + + this.handleCopyUrl(img.url, e)} + sx={{ + p: 0.5, + bgcolor: 'rgba(255, 255, 255, 0.9)', + boxShadow: 1, + '&:hover': { bgcolor: '#fff' }, + }} + > + + + + + this.handleOpenUrl(img.url, e)} + sx={{ + p: 0.5, + bgcolor: 'rgba(255, 255, 255, 0.9)', + boxShadow: 1, + '&:hover': { bgcolor: '#fff' }, + }} + > + + + + + )} + + ); + })} + + )} + + + + + {images.length > 0 ? `${images.length} Ergebnisse gefunden` : ''} + + + + + ); + } +} diff --git a/src/components/brand/BrandLabel.jsx b/src/components/brand/BrandLabel.jsx new file mode 100644 index 0000000..d1c8978 --- /dev/null +++ b/src/components/brand/BrandLabel.jsx @@ -0,0 +1,53 @@ +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import BrandThumb from './BrandThumb'; + +export default function BrandLabel({ + brand, + name: nameProp, + thumbHash, + size = 18, + spacing = 0.5, + typographyProps, + fallback = null, + sx, +}) { + const brandName = nameProp ?? (typeof brand === 'string' ? brand : brand?.name); + if (!brandName && !thumbHash && !brand) return null; + + return ( + + + {brandName ? ( + + {brandName} + + ) : null} + + ); +} diff --git a/src/components/brand/BrandThumb.jsx b/src/components/brand/BrandThumb.jsx new file mode 100644 index 0000000..850d836 --- /dev/null +++ b/src/components/brand/BrandThumb.jsx @@ -0,0 +1,112 @@ +import Box from '@mui/material/Box'; +import ThumbHashImage, { getThumbHashDimensions } from '../article/ThumbHashImage'; +import brandAutomat from '../../automats/brandAutomat'; + +/** + * Extracts thumbHash from various brand representations: + * - Direct thumbHash / thumbhash property + * - Brand with pics array (e.g. brand.pics[0].thumbHash) + * - Brand by name or id looked up in brandAutomat.state.brands + */ +export function extractBrandThumbInfo(brand, thumbHashOverride) { + if (thumbHashOverride) { + const dims = getThumbHashDimensions(thumbHashOverride); + return { + thumbHash: thumbHashOverride, + thumbW: dims?.w ?? null, + thumbH: dims?.h ?? null, + }; + } + + if (!brand) return null; + + // Direct thumbHash on brand object + let hash = brand.thumbHash ?? brand.thumbhash; + let thumbW = brand.thumbW ?? brand.thumbw; + let thumbH = brand.thumbH ?? brand.thumbh; + + // Check pics array + if (!hash && Array.isArray(brand.pics) && brand.pics.length > 0) { + const firstPic = brand.pics[0]; + hash = firstPic?.thumbHash ?? firstPic?.thumbhash; + thumbW = firstPic?.thumbW ?? firstPic?.thumbw; + thumbH = firstPic?.thumbH ?? firstPic?.thumbh; + } + + // Fallback: look up in brandAutomat + if (!hash) { + const brands = brandAutomat.state?.brands; + if (Array.isArray(brands)) { + const id = typeof brand === 'object' ? brand.id : (typeof brand === 'number' ? brand : null); + const name = typeof brand === 'object' ? brand.name : (typeof brand === 'string' ? brand : null); + const found = brands.find( + (b) => + (id != null && b.id === id) || + (name && String(b.name || '').trim().toLowerCase() === String(name).trim().toLowerCase()) + ); + if (found) { + hash = found.thumbHash ?? found.thumbhash ?? found.pics?.[0]?.thumbHash ?? found.pics?.[0]?.thumbhash; + thumbW = found.thumbW ?? found.thumbw ?? found.pics?.[0]?.thumbW ?? found.pics?.[0]?.thumbw; + thumbH = found.thumbH ?? found.thumbh ?? found.pics?.[0]?.thumbH ?? found.pics?.[0]?.thumbh; + } + } + } + + if (!hash) return null; + + if (thumbW == null || thumbH == null) { + const dims = getThumbHashDimensions(hash); + if (dims) { + thumbW = dims.w; + thumbH = dims.h; + } + } + + return { thumbHash: hash, thumbW, thumbH }; +} + +export default function BrandThumb({ + brand, + thumbHash: thumbHashProp, + size = 20, + fallback = null, + sx, +}) { + const info = extractBrandThumbInfo(brand, thumbHashProp); + + if (!info?.thumbHash) { + return fallback; + } + + const { thumbHash, thumbW, thumbH } = info; + const ratio = (thumbW && thumbH) ? (thumbW / thumbH) : 1; + const calcWidth = Math.round(size * Math.max(0.6, Math.min(ratio, 2.5))); + + return ( + = 24 ? '4px' : '3px', + overflow: 'hidden', + flexShrink: 0, + verticalAlign: 'middle', + bgcolor: 'rgba(0, 0, 0, 0.05)', + boxShadow: '0 0 0 1px rgba(0, 0, 0, 0.12)', + ...sx, + }} + > + + + ); +} diff --git a/src/components/brand/index.js b/src/components/brand/index.js new file mode 100644 index 0000000..b265eaa --- /dev/null +++ b/src/components/brand/index.js @@ -0,0 +1,3 @@ +export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb'; +export { default as BrandLabel } from './BrandLabel'; +export { default as BrandImageSearchDialog } from './BrandImageSearchDialog'; diff --git a/src/components/manifest/ArticleSearchDialog.jsx b/src/components/manifest/ArticleSearchDialog.jsx index 4b39529..b069914 100644 --- a/src/components/manifest/ArticleSearchDialog.jsx +++ b/src/components/manifest/ArticleSearchDialog.jsx @@ -22,6 +22,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined'; import LinkIcon from '@mui/icons-material/Link'; import { StorageManagerContext } from '../../api/storageManagerContext'; import ArticlePhoto from '../article/ArticlePhoto'; +import { BrandLabel } from '../brand'; export default class ArticleSearchDialog extends PureComponent { static contextType = StorageManagerContext; @@ -291,15 +292,28 @@ export default class ArticleSearchDialog extends PureComponent { } secondary={ - primaryEan ? ( - - EAN: {primaryEan} - - ) : null + + {(article.brand?.name || article.brandName) && ( + + )} + {primaryEan ? ( + + EAN: {primaryEan} + + ) : null} + } /> + + this.setState({ searchBrand: null })} + /> ); } diff --git a/src/utils/cropLogo.js b/src/utils/cropLogo.js new file mode 100644 index 0000000..3a2bd90 --- /dev/null +++ b/src/utils/cropLogo.js @@ -0,0 +1,339 @@ +/** + * Utility to download, auto-crop (white and transparency margins), and encode brand logos to AVIF. + */ + +const MAX_EDGE = 1000; +const AVIF_QUALITY = 85; +const AVIF_SPEED = 8; +const WHITE_THRESHOLD = 240; // Pixels with R,G,B >= 240 are considered near-white + +let encodeAvif; + +async function getAvifEncoder() { + if (!encodeAvif) { + const { default: encode } = await import('@jsquash/avif/encode'); + encodeAvif = encode; + } + return encodeAvif; +} + +/** + * Downloads image from URL with fallback to streamed dataUrl if CORS fails. + * @param {string} url + * @param {string|null} fallbackDataUrl + * @returns {Promise} + */ +export async function downloadLogoImage(url, fallbackDataUrl) { + // Strategy 1: Attempt direct fetch with CORS mode + if (url) { + try { + const resp = await fetch(url, { mode: 'cors' }); + if (resp.ok) { + const blob = await resp.blob(); + return await loadImageFromBlob(blob); + } + } catch { + // CORS or network failure, proceed to fallbacks + } + } + + // Strategy 2: Attempt loading with HTMLImageElement crossOrigin + if (url) { + try { + return await new Promise((resolve, reject) => { + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onload = () => resolve(img); + img.onerror = reject; + img.src = url; + }); + } catch { + // Canvas might still be tainted or image failed to load + } + } + + // Strategy 3: Use the provided dataUrl (streamed WebP/JPEG thumbnail from Serper SSE) + if (fallbackDataUrl) { + const resp = await fetch(fallbackDataUrl); + const blob = await resp.blob(); + return await loadImageFromBlob(blob); + } + + throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).'); +} + +function loadImageFromBlob(blob) { + return new Promise((resolve, reject) => { + const url = URL.createObjectURL(blob); + const img = new Image(); + img.onload = () => { + URL.revokeObjectURL(url); + resolve(img); + }; + img.onerror = (e) => { + URL.revokeObjectURL(url); + reject(e); + }; + img.src = url; + }); +} + +/** + * Checks if a pixel is background (near-white or transparent). + */ +function isBackgroundPixel(r, g, b, a) { + if (a <= 25) return true; + return r >= WHITE_THRESHOLD && g >= WHITE_THRESHOLD && b >= WHITE_THRESHOLD; +} + +/** + * Flood-fills white/transparent pixels from all borders, setting outer background to alpha 0, + * and crops strictly to the remaining content bounding box (removing outer and internal margins). + * + * @param {HTMLImageElement} img + * @returns {HTMLCanvasElement} Cropped canvas with transparent background + */ +export function autoCropWhiteAndTransparent(img) { + const width = img.naturalWidth || img.width; + const height = img.naturalHeight || img.height; + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + if (!ctx) { + throw new Error('Canvas 2D-Kontext nicht verfügbar'); + } + + ctx.drawImage(img, 0, 0); + + let imgData; + try { + imgData = ctx.getImageData(0, 0, width, height); + } catch (err) { + // Canvas tainted by CORS, throw to let caller know + throw new Error('Canvas durch CORS blockiert: ' + (err?.message || err)); + } + + const data = imgData.data; + const totalPixels = width * height; + const visited = new Uint8Array(totalPixels); + + // Queue for BFS flood fill from edges + // Packed format: y * width + x + const queue = []; + + // Seed top and bottom rows + for (let x = 0; x < width; x++) { + const topIdx = x; + const botIdx = (height - 1) * width + x; + + const topOffset = topIdx * 4; + if (isBackgroundPixel(data[topOffset], data[topOffset + 1], data[topOffset + 2], data[topOffset + 3])) { + visited[topIdx] = 1; + queue.push(topIdx); + } + + const botOffset = botIdx * 4; + if (isBackgroundPixel(data[botOffset], data[botOffset + 1], data[botOffset + 2], data[botOffset + 3])) { + visited[botIdx] = 1; + queue.push(botIdx); + } + } + + // Seed left and right columns + for (let y = 0; y < height; y++) { + const leftIdx = y * width; + const rightIdx = y * width + (width - 1); + + const leftOffset = leftIdx * 4; + if (!visited[leftIdx] && isBackgroundPixel(data[leftOffset], data[leftOffset + 1], data[leftOffset + 2], data[leftOffset + 3])) { + visited[leftIdx] = 1; + queue.push(leftIdx); + } + + const rightOffset = rightIdx * 4; + if (!visited[rightIdx] && isBackgroundPixel(data[rightOffset], data[rightOffset + 1], data[rightOffset + 2], data[rightOffset + 3])) { + visited[rightIdx] = 1; + queue.push(rightIdx); + } + } + + // BFS flood-fill to turn all connected outer background pixels transparent + let head = 0; + while (head < queue.length) { + const curr = queue[head++]; + const cx = curr % width; + const cy = (curr / width) | 0; + + // Set pixel to transparent + const offset = curr * 4; + data[offset + 3] = 0; // alpha = 0 + + // Check 4 neighbors + const neighbors = [ + cx > 0 ? curr - 1 : -1, + cx < width - 1 ? curr + 1 : -1, + cy > 0 ? curr - width : -1, + cy < height - 1 ? curr + width : -1, + ]; + + for (let i = 0; i < 4; i++) { + const n = neighbors[i]; + if (n !== -1 && !visited[n]) { + const noffset = n * 4; + if (isBackgroundPixel(data[noffset], data[noffset + 1], data[noffset + 2], data[noffset + 3])) { + visited[n] = 1; + queue.push(n); + } + } + } + } + + // Update canvas with cleared transparency + ctx.putImageData(imgData, 0, 0); + + // Scan bounding box of non-transparent content + let minX = width; + let minY = height; + let maxX = -1; + let maxY = -1; + + for (let y = 0; y < height; y++) { + const rowOffset = y * width * 4; + for (let x = 0; x < width; x++) { + const offset = rowOffset + x * 4; + const alpha = data[offset + 3]; + const r = data[offset]; + const g = data[offset + 1]; + const b = data[offset + 2]; + + // Non-transparent and not near-white remnant + if (alpha > 20 && !(r >= 248 && g >= 248 && b >= 248)) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + + // If no colored content was detected, fall back to any visible alpha + if (maxX === -1) { + for (let y = 0; y < height; y++) { + const rowOffset = y * width * 4; + for (let x = 0; x < width; x++) { + const offset = rowOffset + x * 4; + if (data[offset + 3] > 20) { + if (x < minX) minX = x; + if (x > maxX) maxX = x; + if (y < minY) minY = y; + if (y > maxY) maxY = y; + } + } + } + } + + // If entire image is blank, return original + if (maxX === -1 || minX > maxX || minY > maxY) { + return canvas; + } + + // Crop tightly to bounding box + const cropW = maxX - minX + 1; + const cropH = maxY - minY + 1; + + const croppedCanvas = document.createElement('canvas'); + croppedCanvas.width = cropW; + croppedCanvas.height = cropH; + + const croppedCtx = croppedCanvas.getContext('2d'); + if (!croppedCtx) { + return canvas; + } + + croppedCtx.drawImage(canvas, minX, minY, cropW, cropH, 0, 0, cropW, cropH); + return croppedCanvas; +} + +/** + * Resizes canvas if larger than MAX_EDGE (maintaining aspect ratio), + * and encodes to AVIF format. + * + * @param {HTMLCanvasElement} canvas + * @param {string} [filename='brandpic.avif'] + * @returns {Promise} + */ +export async function encodeCanvasToAvif(canvas, filename = 'brandpic.avif') { + let targetW = canvas.width; + let targetH = canvas.height; + + if (targetW > MAX_EDGE || targetH > MAX_EDGE) { + const scale = Math.min(MAX_EDGE / targetW, MAX_EDGE / targetH); + targetW = Math.round(targetW * scale); + targetH = Math.round(targetH * scale); + } + + const finalCanvas = document.createElement('canvas'); + finalCanvas.width = targetW; + finalCanvas.height = targetH; + + const ctx = finalCanvas.getContext('2d'); + if (!ctx) { + throw new Error('Canvas 2D-Kontext nicht verfügbar'); + } + + ctx.imageSmoothingEnabled = true; + ctx.imageSmoothingQuality = 'high'; + ctx.drawImage(canvas, 0, 0, canvas.width, canvas.height, 0, 0, targetW, targetH); + + const finalImageData = ctx.getImageData(0, 0, targetW, targetH); + const encode = await getAvifEncoder(); + + const avifBuffer = await encode(finalImageData, { + quality: AVIF_QUALITY, + speed: AVIF_SPEED, + }); + + return new File([avifBuffer], filename, { type: 'image/avif' }); +} + +/** + * Complete pipeline: + * 1. Download image from url (or fallbackDataUrl) + * 2. Auto-crop white & transparency margins + * 3. Encode to AVIF + * + * @param {string} url + * @param {string|null} fallbackDataUrl + * @returns {Promise} + */ +export async function downloadAndPrepareLogoAvif(url, fallbackDataUrl) { + let img; + try { + img = await downloadLogoImage(url, fallbackDataUrl); + } catch (downloadErr) { + // If url direct download completely failed, try using fallbackDataUrl if available + if (fallbackDataUrl) { + img = await downloadLogoImage(null, fallbackDataUrl); + } else { + throw downloadErr; + } + } + + let croppedCanvas; + try { + croppedCanvas = autoCropWhiteAndTransparent(img); + } catch { + // In case of any canvas security error with external url, fallback to dataUrl + if (fallbackDataUrl) { + const fallbackImg = await downloadLogoImage(null, fallbackDataUrl); + croppedCanvas = autoCropWhiteAndTransparent(fallbackImg); + } else { + throw new Error('Das Bild konnte aufgrund von CORS-Sicherheitseinstellungen nicht freigestellt werden.'); + } + } + + return await encodeCanvasToAvif(croppedCanvas, 'brandpic.avif'); +}