This commit is contained in:
seb
2026-09-27 16:19:08 +02:00
parent 559307675c
commit 0f875f347e
16 changed files with 1768 additions and 47 deletions

View File

@@ -589,6 +589,27 @@ export const brand = {
delete(id) { delete(id) {
return http(`/api/brand/${encodeURIComponent(id)}`, { method: 'DELETE' }); 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. * Append-only stock and payment ledger.
* Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at } * Entry = { id, manifestarticle_id, sku, transaction_type, amount, transaction_text, hash_version, prev_hash, created_at }

View File

@@ -64,6 +64,27 @@ const actions = {
brandAutomat.setState({ brands }); 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 to idle state (called on logout / company switch). */
reset() { reset() {
brandAutomat.setState(initialState); brandAutomat.setState(initialState);

View File

@@ -2,6 +2,7 @@ import { Component } from 'react';
import Alert from '@mui/material/Alert'; import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import Button from '@mui/material/Button'; import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
import CircularProgress from '@mui/material/CircularProgress'; import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog'; import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent'; import DialogContent from '@mui/material/DialogContent';
@@ -13,14 +14,26 @@ import InputAdornment from '@mui/material/InputAdornment';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton'; import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText'; import ListItemText from '@mui/material/ListItemText';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack'; 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 TextField from '@mui/material/TextField';
import ToggleButton from '@mui/material/ToggleButton'; import ToggleButton from '@mui/material/ToggleButton';
import ToggleButtonGroup from '@mui/material/ToggleButtonGroup'; import ToggleButtonGroup from '@mui/material/ToggleButtonGroup';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import BusinessIcon from '@mui/icons-material/Business'; 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 BackendError from './BackendError';
import { company } from '../api'; import { company, number as numberApi } from '../api';
import { StorageManagerContext } from '../api/storageManagerContext'; import { StorageManagerContext } from '../api/storageManagerContext';
import authAutomat from '../automats/authAutomat'; import authAutomat from '../automats/authAutomat';
import { import {
@@ -30,6 +43,67 @@ import {
} from '../utils/companyForm'; } from '../utils/companyForm';
import { backendErrorFrom, isBackendFailure } from '../utils/backendError'; 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 { class CompanySetupDialog extends Component {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -44,11 +118,15 @@ class CompanySetupDialog extends Component {
iso639: defaultIso639(), iso639: defaultIso639(),
taxrate: '19', taxrate: '19',
reducedtaxrate: '7', reducedtaxrate: '7',
selectedPreset: 'standard',
numbers: { ...PRESETS[0].values },
}; };
this.handleSelect = this.handleSelect.bind(this); this.handleSelect = this.handleSelect.bind(this);
this.handleCreate = this.handleCreate.bind(this); this.handleCreate = this.handleCreate.bind(this);
this.handleLogout = this.handleLogout.bind(this); this.handleLogout = this.handleLogout.bind(this);
this.handleClose = this.handleClose.bind(this); this.handleClose = this.handleClose.bind(this);
this.applyPreset = this.applyPreset.bind(this);
this.handleNumberChange = this.handleNumberChange.bind(this);
} }
componentDidMount() { 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) { async handleCreate(event) {
event.preventDefault(); event.preventDefault();
const { name, iso639, taxrate, reducedtaxrate } = this.state; const { name, iso639, taxrate, reducedtaxrate, numbers } = this.state;
const parsedTaxrate = parseTaxRate(taxrate); const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
const canCreate = const canCreate =
@@ -103,8 +203,29 @@ class CompanySetupDialog extends Component {
taxrate: parsedTaxrate, taxrate: parsedTaxrate,
reducedtaxrate: parsedReducedtaxrate, 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.updateAuthMe();
await this.context.updateCompany(); await this.context.updateCompany();
await this.context.updateNumber?.();
} catch (err) { } catch (err) {
this.setState({ error: backendErrorFrom(err) }); this.setState({ error: backendErrorFrom(err) });
} finally { } finally {
@@ -126,8 +247,18 @@ class CompanySetupDialog extends Component {
} }
render() { render() {
const { companies, loading, busy, error, name, iso639, taxrate, reducedtaxrate } = const {
this.state; companies,
loading,
busy,
error,
name,
iso639,
taxrate,
reducedtaxrate,
selectedPreset,
numbers,
} = this.state;
const hasCompanies = companies.length > 0; const hasCompanies = companies.length > 0;
const parsedTaxrate = parseTaxRate(taxrate); const parsedTaxrate = parseTaxRate(taxrate);
const parsedReducedtaxrate = parseTaxRate(reducedtaxrate); const parsedReducedtaxrate = parseTaxRate(reducedtaxrate);
@@ -138,20 +269,25 @@ class CompanySetupDialog extends Component {
parsedReducedtaxrate != null; parsedReducedtaxrate != null;
return ( return (
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth> <Dialog
<DialogTitle> open
onClose={this.handleClose}
maxWidth={hasCompanies ? 'sm' : 'md'}
fullWidth
>
<DialogTitle sx={{ pb: 1 }}>
{loading {loading
? 'Unternehmen' ? 'Unternehmen'
: hasCompanies : hasCompanies
? 'Unternehmen auswählen' ? 'Unternehmen auswählen'
: 'Unternehmen anlegen'} : 'Willkommen bei Dash'}
</DialogTitle> </DialogTitle>
<DialogContent> <DialogContent>
{!loading ? ( {!loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
{hasCompanies {hasCompanies
? 'Wählen Sie ein bestehendes Unternehmen oder legen Sie ein neues an.' ? '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.'}
</Typography> </Typography>
) : null} ) : null}
@@ -191,7 +327,7 @@ class CompanySetupDialog extends Component {
{hasCompanies ? <Divider>oder</Divider> : null} {hasCompanies ? <Divider>oder</Divider> : null}
<Box component="form" onSubmit={this.handleCreate} noValidate> <Box component="form" onSubmit={this.handleCreate} noValidate>
<Stack spacing={2}> <Stack spacing={2.5}>
{hasCompanies ? ( {hasCompanies ? (
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography> <Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
) : null} ) : null}
@@ -200,7 +336,7 @@ class CompanySetupDialog extends Component {
fullWidth fullWidth
required required
size="small" size="small"
label="Name" label="Unternehmensname"
value={name} value={name}
disabled={busy} disabled={busy}
onChange={(e) => this.setState({ name: e.target.value })} onChange={(e) => this.setState({ name: e.target.value })}
@@ -225,7 +361,7 @@ class CompanySetupDialog extends Component {
</ToggleButtonGroup> </ToggleButtonGroup>
</FormControl> </FormControl>
<Stack direction="row" spacing={1}> <Stack direction="row" spacing={2}>
<TextField <TextField
fullWidth fullWidth
required required
@@ -262,8 +398,143 @@ class CompanySetupDialog extends Component {
/> />
</Stack> </Stack>
<Button type="submit" variant="contained" disabled={busy || !canCreate}> {/* Nummernkreise Section (when no company was created yet or creating new) */}
Anlegen <Box sx={{ pt: 1 }}>
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
Nummernkreise & Belegnummern
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
Wählen Sie eine Voreinstellung oder passen Sie Präfix, Stellen und Startnummer an:
</Typography>
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap sx={{ mb: 1.5 }}>
{PRESETS.map((p) => {
const isSelected = selectedPreset === p.id;
return (
<Button
key={p.id}
size="small"
variant={isSelected ? 'contained' : 'outlined'}
color={isSelected ? 'primary' : 'inherit'}
disabled={busy}
onClick={() => this.applyPreset(p.id)}
startIcon={isSelected ? <AutoAwesomeIcon sx={{ fontSize: 16 }} /> : null}
sx={{ textTransform: 'none', borderRadius: 2 }}
>
{p.label}
</Button>
);
})}
{selectedPreset === 'custom' ? (
<Chip
size="small"
label="Benutzerdefiniert"
color="secondary"
variant="outlined"
sx={{ height: 30, borderRadius: 2 }}
/>
) : null}
</Stack>
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 1.5 }}>
<Table size="small">
<TableHead>
<TableRow sx={{ bgcolor: 'action.hover' }}>
<TableCell sx={{ fontWeight: 600 }}>Bereich</TableCell>
<TableCell sx={{ fontWeight: 600, width: 130 }}>Präfix</TableCell>
<TableCell sx={{ fontWeight: 600, width: 90 }}>Stellen</TableCell>
<TableCell sx={{ fontWeight: 600, width: 110 }}>Startnr.</TableCell>
<TableCell sx={{ fontWeight: 600 }}>Vorschau</TableCell>
</TableRow>
</TableHead>
<TableBody>
{NUMBER_SCHEMES.map(({ type, label, icon: Icon }) => {
const cfg = numbers[type] || { prefix: '', padding: 5, next: 1 };
return (
<TableRow key={type} hover>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Icon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0 }} />
<Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
{label}
</Typography>
</Box>
</TableCell>
<TableCell>
<TextField
size="small"
value={cfg.prefix}
disabled={busy}
onChange={(e) => this.handleNumberChange(type, 'prefix', e.target.value)}
placeholder="z.B. ART-"
sx={{ input: { fontFamily: 'monospace', fontSize: '0.85rem' } }}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={cfg.padding}
disabled={busy}
slotProps={{ htmlInput: { min: 0, max: 20, step: 1 } }}
onChange={(e) => this.handleNumberChange(type, 'padding', e.target.value)}
/>
</TableCell>
<TableCell>
<TextField
size="small"
type="number"
value={cfg.next}
disabled={busy}
slotProps={{ htmlInput: { min: 0, step: 1 } }}
onChange={(e) => this.handleNumberChange(type, 'next', e.target.value)}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.75, flexWrap: 'wrap' }}>
<Chip
size="small"
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 0)}
sx={{
fontFamily: 'monospace',
fontWeight: 700,
bgcolor: 'primary.50',
color: 'primary.dark',
border: '1px solid',
borderColor: 'primary.200',
}}
/>
<Typography variant="caption" color="text.disabled">
→
</Typography>
<Chip
size="small"
variant="outlined"
label={formatNumberPreview(cfg.prefix, cfg.padding, cfg.next, 1)}
sx={{
fontFamily: 'monospace',
fontSize: '0.75rem',
color: 'text.secondary',
}}
/>
</Box>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
</Box>
<Button
type="submit"
variant="contained"
size="large"
disabled={busy || !canCreate}
sx={{ mt: 1 }}
>
Unternehmen anlegen
</Button> </Button>
</Stack> </Stack>
</Box> </Box>
@@ -287,3 +558,4 @@ class CompanySetupDialog extends Component {
} }
export default CompanySetupDialog; export default CompanySetupDialog;

View File

@@ -2,19 +2,34 @@ import { useEffect, useRef } from 'react';
import Box from '@mui/material/Box'; import Box from '@mui/material/Box';
import { thumbHashToRGBA } from 'thumbhash'; import { thumbHashToRGBA } from 'thumbhash';
function decodeThumbHash(thumbHash) { export function decodeThumbHash(thumbHash) {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0)); if (!thumbHash || typeof thumbHash !== 'string') return null;
return thumbHashToRGBA(bytes); 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; 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(); const decoded = decodeThumbHash(thumbHash);
if (width === 0 || height === 0) return; if (!decoded) return;
const { w, h, rgba } = decoded;
const { w, h, rgba } = decodeThumbHash(thumbHash);
const src = document.createElement('canvas'); const src = document.createElement('canvas');
src.width = w; src.width = w;
src.height = h; src.height = h;
@@ -34,24 +49,24 @@ function paintThumbHash(canvas, thumbHash) {
ctx.drawImage(src, 0, 0, canvas.width, canvas.height); 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); const canvasRef = useRef(null);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas || !thumbHash) return; if (!canvas || !thumbHash) return;
paintThumbHash(canvas, thumbHash); paintThumbHash(canvas, thumbHash, width, height);
const parent = canvas.parentElement; const parent = canvas.parentElement;
if (!parent || typeof ResizeObserver === 'undefined') return undefined; if (!parent || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(() => { const observer = new ResizeObserver(() => {
paintThumbHash(canvas, thumbHash); paintThumbHash(canvas, thumbHash, width, height);
}); });
observer.observe(parent); observer.observe(parent);
return () => observer.disconnect(); return () => observer.disconnect();
}, [thumbHash]); }, [thumbHash, width, height]);
return ( return (
<Box <Box

View File

@@ -7,6 +7,9 @@ import { EditablePanelTile } from 'dashgui/panels';
import { StorageManagerContext } from '../../../api/storageManagerContext'; import { StorageManagerContext } from '../../../api/storageManagerContext';
import { requireDefined, requireObject } from '../../../utils/apiShape'; import { requireDefined, requireObject } from '../../../utils/apiShape';
import { notifyError } from '../../../utils/notify'; import { notifyError } from '../../../utils/notify';
import InputAdornment from '@mui/material/InputAdornment';
import { BrandLabel, BrandThumb } from '../../brand';
class PanelValue extends Component { class PanelValue extends Component {
render() { render() {
const { label, children } = this.props; const { label, children } = this.props;
@@ -27,7 +30,12 @@ class PanelValue extends Component {
} }
function brandDraftValue(article) { function brandDraftValue(article) {
return { name: article?.brand?.name ?? '' }; return {
name: article?.brand?.name ?? '',
thumbHash: article?.brand?.thumbHash ?? null,
thumbW: article?.brand?.thumbW ?? null,
thumbH: article?.brand?.thumbH ?? null,
};
} }
function brandDraftDirty(draft, saved) { function brandDraftDirty(draft, saved) {
@@ -43,6 +51,11 @@ const SECOND_ROW_MIN_HEIGHT = 220;
class BrandEdit extends Component { class BrandEdit extends Component {
render() { render() {
const { draft, onChange, inputRef, brands } = this.props; const { draft, onChange, inputRef, brands } = this.props;
const trimmed = String(draft.name ?? '').trim().toLowerCase();
const matchingBrand = brands.find(
(b) => String(b.name ?? '').toLowerCase() === trimmed,
);
return ( return (
<Autocomplete <Autocomplete
fullWidth fullWidth
@@ -57,6 +70,21 @@ class BrandEdit extends Component {
onChange={(_event, option) => { onChange={(_event, option) => {
onChange({ name: option ? String(option.name ?? '') : '' }); onChange({ name: option ? String(option.name ?? '') : '' });
}} }}
renderOption={(props, option) => {
const { key, ...optionProps } = props;
const name = typeof option === 'string' ? option : String(option.name ?? '');
return (
<Box
component="li"
key={key}
{...optionProps}
sx={{ display: 'flex', alignItems: 'center', gap: 1 }}
>
<BrandThumb brand={option} size={20} />
<span>{name}</span>
</Box>
);
}}
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
@@ -65,6 +93,16 @@ class BrandEdit extends Component {
size="small" size="small"
slotProps={{ slotProps={{
...params.slotProps, ...params.slotProps,
input: {
...params.slotProps?.input,
startAdornment: matchingBrand ? (
<InputAdornment position="start" sx={{ mr: 0.5 }}>
<BrandThumb brand={matchingBrand} size={20} />
</InputAdornment>
) : (
params.slotProps?.input?.startAdornment
),
},
inputLabel: { inputLabel: {
...params.slotProps?.inputLabel, ...params.slotProps?.inputLabel,
shrink: true, shrink: true,
@@ -152,8 +190,19 @@ export default class ArticleViewBrand extends PureComponent {
onSave={this.handleSave} onSave={this.handleSave}
onError={notifyError} onError={notifyError}
isDirty={brandDraftDirty} isDirty={brandDraftDirty}
display={
<PanelValue>
{value.name ? (
<BrandLabel
brand={articleData?.brand || value}
name={value.name}
size={22}
typographyProps={{ variant: 'body2', fontWeight: 600 }}
/>
) : null}
</PanelValue>
}
canSave={brandDraftValid} canSave={brandDraftValid}
display={<PanelValue>{value.name || null}</PanelValue>}
edit={({ draft, onChange, inputRef }) => ( edit={({ draft, onChange, inputRef }) => (
<BrandEdit <BrandEdit
brands={brands} brands={brands}

View File

@@ -0,0 +1,623 @@
import { PureComponent } from 'react';
import Alert from '@mui/material/Alert';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import LinearProgress from '@mui/material/LinearProgress';
import Skeleton from '@mui/material/Skeleton';
import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import Chip from '@mui/material/Chip';
import CloseIcon from '@mui/icons-material/Close';
import SearchIcon from '@mui/icons-material/Search';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import BrokenImageIcon from '@mui/icons-material/BrokenImage';
import CheckCircleIcon from '@mui/icons-material/CheckCircle';
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
import { enqueueSnackbar } from 'notistack';
import { serper } from '../../api';
import brandAutomat from '../../automats/brandAutomat';
import { downloadAndPrepareLogoAvif } from '../../utils/cropLogo';
/**
* Dialog that searches Google Images via Serper.dev SSE stream for a brand logo.
*/
export default class BrandImageSearchDialog extends PureComponent {
constructor(props) {
super(props);
const brandName = props.brand?.name ? String(props.brand.name).trim() : '';
this.state = {
query: brandName ? `${brandName} logo` : 'logo',
status: 'idle', // 'idle' | 'loading' | 'streaming' | 'done' | 'error'
error: null,
images: [],
loadedThumbs: 0,
processingId: null,
processingStep: '',
};
this.abortController = null;
}
componentDidMount() {
if (this.props.open) {
this.initSearch();
}
}
componentDidUpdate(prevProps) {
if (this.props.open && !prevProps.open) {
const brandName = this.props.brand?.name ? String(this.props.brand.name).trim() : '';
const nextQuery = brandName ? `${brandName} logo` : 'logo';
this.setState(
{
query: nextQuery,
status: 'idle',
error: null,
images: [],
loadedThumbs: 0,
processingId: null,
processingStep: '',
},
() => {
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 (
<Dialog
open={open}
onClose={processingId ? undefined : onClose}
maxWidth="md"
fullWidth
scroll="paper"
aria-labelledby="brand-image-search-dialog-title"
>
<DialogTitle
id="brand-image-search-dialog-title"
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1.5,
px: 2.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<ImageSearchIcon color="primary" sx={{ fontSize: 24 }} />
<Typography variant="h6" component="span" sx={{ fontWeight: 600 }}>
Logo-Suche: {brand?.name || 'Marke'}
</Typography>
</Box>
<IconButton
size="small"
onClick={onClose}
disabled={Boolean(processingId)}
aria-label="Schließen"
sx={{ color: 'text.secondary' }}
>
<CloseIcon fontSize="small" />
</IconButton>
</DialogTitle>
<DialogContent dividers sx={{ p: 2.5 }}>
{/* Search bar */}
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
this.initSearch();
}}
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
>
<TextField
size="small"
fullWidth
value={query}
disabled={isSearching || Boolean(processingId)}
onChange={(e) => this.setState({ query: e.target.value })}
onKeyDown={this.handleQueryKeyDown}
placeholder="z. B. Nike logo png"
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" color="action" />
</InputAdornment>
),
},
}}
/>
<Button
type="submit"
variant="contained"
disabled={isSearching || !query.trim() || Boolean(processingId)}
startIcon={
isSearching ? (
<CircularProgress size={16} color="inherit" />
) : (
<SearchIcon />
)
}
sx={{ flexShrink: 0 }}
>
Suchen
</Button>
</Box>
{/* User helper tip */}
{images.length > 0 && !error && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
mb: 1.5,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
}}
>
<AutoFixHighIcon color="primary" sx={{ fontSize: 17 }} />
<Typography variant="caption" color="text.secondary">
Klicken Sie auf ein Logo: Es wird heruntergeladen, automatisch freigestellt (Weiß- &amp; Transparenz-Ränder entfernt), in AVIF konvertiert und als Markenbild hinterlegt.
</Typography>
</Box>
)}
{/* Progress / Status banner */}
{status === 'loading' && <LinearProgress sx={{ mb: 2, borderRadius: 1 }} />}
{status === 'streaming' && (
<Box sx={{ mb: 2 }}>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography variant="caption" color="text.secondary">
Thumbnails werden gestreamt...
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{loadedThumbs} / {images.length}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={
images.length > 0
? Math.round((loadedThumbs / images.length) * 100)
: 0
}
sx={{ borderRadius: 1 }}
/>
</Box>
)}
{/* Error banner */}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{error}
</Typography>
{isSerperKeyError && (
<Typography variant="caption" color="inherit" sx={{ display: 'block', mt: 0.5 }}>
Hinweis: Sie können Ihren Serper API-Key im Hauptmenü unter{' '}
<strong>Firmeneinstellungen → Einstellungen (Serper API-Key)</strong> hinterlegen.
</Typography>
)}
</Alert>
)}
{/* Empty state when done */}
{status === 'done' && images.length === 0 && !error && (
<Box sx={{ textAlign: 'center', py: 6 }}>
<ImageSearchIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 1 }} />
<Typography variant="body2" color="text.secondary">
Keine Bilder zu „{query}“ gefunden.
</Typography>
</Box>
)}
{/* Image Grid */}
{images.length > 0 && (
<Box
sx={{
display: 'grid',
gridTemplateColumns: {
xs: 'repeat(auto-fill, minmax(110px, 1fr))',
sm: 'repeat(auto-fill, minmax(130px, 1fr))',
md: 'repeat(auto-fill, minmax(140px, 1fr))',
},
gap: 1.5,
maxHeight: '55vh',
overflowY: 'auto',
p: 0.5,
}}
>
{images.map((img) => {
const isThisProcessing = processingId === img.id;
const isOtherProcessing = Boolean(processingId) && !isThisProcessing;
return (
<Box
key={img.id}
onClick={() => 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 ? (
<Box
component="img"
src={img.dataUrl}
alt={brand?.name || 'Logo'}
sx={{
display: 'block',
maxWidth: '92%',
maxHeight: '92%',
objectFit: 'contain',
}}
/>
) : img.failed ? (
<Tooltip title="Vorschau konnte nicht geladen werden">
<BrokenImageIcon sx={{ color: 'text.disabled', fontSize: 28 }} />
</Tooltip>
) : (
<Skeleton
variant="rectangular"
width="100%"
height="100%"
animation="wave"
sx={{ bgcolor: 'action.hover' }}
/>
)}
{/* Dimensions badge */}
{img.width && img.height ? (
<Chip
size="small"
label={`${img.width}×${img.height}`}
sx={{
position: 'absolute',
bottom: 4,
right: 4,
height: 16,
fontSize: '0.62rem',
fontWeight: 600,
bgcolor: 'rgba(0, 0, 0, 0.65)',
color: '#fff',
pointerEvents: 'none',
px: 0.25,
}}
/>
) : null}
{/* Quick selection hover pill */}
{!processingId && (
<Box
className="image-overlay-select"
sx={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
py: 0.6,
px: 0.8,
bgcolor: 'rgba(25, 118, 210, 0.92)',
backdropFilter: 'blur(3px)',
color: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: 0.5,
opacity: 0,
transform: 'translateY(100%)',
transition: 'all 0.18s ease-in-out',
pointerEvents: 'none',
}}
>
<CheckCircleIcon sx={{ fontSize: 13 }} />
<Typography
variant="caption"
sx={{ fontWeight: 600, fontSize: '0.68rem', letterSpacing: 0.2 }}
>
Als Logo wählen
</Typography>
</Box>
)}
{/* Processing overlay for selected card */}
{isThisProcessing && (
<Box
sx={{
position: 'absolute',
inset: 0,
bgcolor: 'rgba(255, 255, 255, 0.92)',
backdropFilter: 'blur(3px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
p: 1,
zIndex: 3,
textAlign: 'center',
}}
>
<CircularProgress size={24} color="primary" sx={{ mb: 1 }} />
<Typography
variant="caption"
sx={{
fontWeight: 600,
fontSize: '0.68rem',
color: 'primary.main',
lineHeight: 1.2,
}}
>
{processingStep || 'Wird freigestellt...'}
</Typography>
</Box>
)}
{/* Hover action overlay (open / copy url) */}
{img.url && !processingId && (
<Box
className="image-overlay-actions"
sx={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 0.5,
opacity: 0,
transition: 'opacity 0.15s',
}}
>
<Tooltip title="URL kopieren">
<IconButton
size="small"
onClick={(e) => this.handleCopyUrl(img.url, e)}
sx={{
p: 0.5,
bgcolor: 'rgba(255, 255, 255, 0.9)',
boxShadow: 1,
'&:hover': { bgcolor: '#fff' },
}}
>
<ContentCopyIcon sx={{ fontSize: 14, color: 'text.primary' }} />
</IconButton>
</Tooltip>
<Tooltip title="In neuem Tab öffnen">
<IconButton
size="small"
onClick={(e) => this.handleOpenUrl(img.url, e)}
sx={{
p: 0.5,
bgcolor: 'rgba(255, 255, 255, 0.9)',
boxShadow: 1,
'&:hover': { bgcolor: '#fff' },
}}
>
<OpenInNewIcon sx={{ fontSize: 14, color: 'text.primary' }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
);
})}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5, justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">
{images.length > 0 ? `${images.length} Ergebnisse gefunden` : ''}
</Typography>
<Button onClick={onClose} color="inherit">
Schließen
</Button>
</DialogActions>
</Dialog>
);
}
}

View File

@@ -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 (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: spacing,
verticalAlign: 'middle',
maxWidth: '100%',
...sx,
}}
>
<BrandThumb
brand={brand}
thumbHash={thumbHash}
size={size}
fallback={fallback}
/>
{brandName ? (
<Typography
component="span"
variant="body2"
{...typographyProps}
sx={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...typographyProps?.sx,
}}
>
{brandName}
</Typography>
) : null}
</Box>
);
}

View File

@@ -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 (
<Box
component="span"
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
height: size,
width: calcWidth,
minWidth: Math.round(size * 0.6),
maxWidth: Math.round(size * 2.5),
borderRadius: size >= 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,
}}
>
<ThumbHashImage
thumbHash={thumbHash}
width={calcWidth}
height={size}
/>
</Box>
);
}

View File

@@ -0,0 +1,3 @@
export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb';
export { default as BrandLabel } from './BrandLabel';
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';

View File

@@ -22,6 +22,7 @@ import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import LinkIcon from '@mui/icons-material/Link'; import LinkIcon from '@mui/icons-material/Link';
import { StorageManagerContext } from '../../api/storageManagerContext'; import { StorageManagerContext } from '../../api/storageManagerContext';
import ArticlePhoto from '../article/ArticlePhoto'; import ArticlePhoto from '../article/ArticlePhoto';
import { BrandLabel } from '../brand';
export default class ArticleSearchDialog extends PureComponent { export default class ArticleSearchDialog extends PureComponent {
static contextType = StorageManagerContext; static contextType = StorageManagerContext;
@@ -291,15 +292,28 @@ export default class ArticleSearchDialog extends PureComponent {
</Box> </Box>
} }
secondary={ secondary={
primaryEan ? ( <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
<Typography {(article.brand?.name || article.brandName) && (
variant="caption" <BrandLabel
color="text.secondary" brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }} name={article.brand?.name || article.brandName}
> size={14}
EAN: {primaryEan} typographyProps={{
</Typography> variant: 'caption',
) : null color: 'text.secondary',
}}
/>
)}
{primaryEan ? (
<Typography
variant="caption"
color="text.secondary"
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
>
EAN: {primaryEan}
</Typography>
) : null}
</Box>
} }
/> />
<Button <Button

View File

@@ -23,6 +23,7 @@ import { formatPrice } from '../../utils/price';
import { primaryPrice, priceBrutto } from '../../utils/articleDetail'; import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat'; import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
import { artikelDetailPath } from '../../pages/artikel/articleRoute'; import { artikelDetailPath } from '../../pages/artikel/articleRoute';
import { BrandLabel } from '../brand';
class ManifestFormLine extends PureComponent { class ManifestFormLine extends PureComponent {
constructor(props) { constructor(props) {
@@ -367,6 +368,19 @@ class ManifestFormLine extends PureComponent {
/> />
)} )}
{/* Brand */}
{(fullArticle.brand?.name || article.brand?.name) && (
<BrandLabel
brand={fullArticle.brand || article.brand}
size={16}
typographyProps={{
variant: 'caption',
color: 'text.secondary',
fontWeight: 500,
}}
/>
)}
{/* Article Name */} {/* Article Name */}
{(fullArticle.name || article.name) && ( {(fullArticle.name || article.name) && (
<Typography <Typography

View File

@@ -27,12 +27,14 @@ const SETTABLE_KEYS = [
'ui.theme', 'ui.theme',
'keys.tavily', 'keys.tavily',
'keys.openrouter', 'keys.openrouter',
'keys.serper',
]; ];
const KEY_LABELS = { const KEY_LABELS = {
'ui.theme': 'UI-Theme', 'ui.theme': 'UI-Theme',
'keys.tavily': 'Tavily API-Key', 'keys.tavily': 'Tavily API-Key',
'keys.openrouter': 'OpenRouter API-Key', 'keys.openrouter': 'OpenRouter API-Key',
'keys.serper': 'Serper API-Key',
}; };
function keyLabel(key) { function keyLabel(key) {

View File

@@ -6,6 +6,7 @@ import { Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price'; import { formatPrice } from '../../utils/price';
import { articleSku } from '../../pages/artikel/articleRoute'; import { articleSku } from '../../pages/artikel/articleRoute';
import ArticlePhoto from '../article/ArticlePhoto'; import ArticlePhoto from '../article/ArticlePhoto';
import { BrandLabel } from '../brand';
export class ArticleTile extends Tile { export class ArticleTile extends Tile {
hasPhoto() { hasPhoto() {
@@ -21,8 +22,23 @@ export class ArticleTile extends Tile {
const { article } = this.props; const { article } = this.props;
if (!article) return null; if (!article) return null;
const sku = articleSku(article); const sku = articleSku(article);
const brand = article.brand;
return ( return (
<> <>
{brand?.name ? (
<BrandLabel
brand={brand}
name={brand.name}
size={16}
spacing={0.5}
typographyProps={{
variant: 'caption',
color: 'text.secondary',
fontWeight: 500,
noWrap: true,
}}
/>
) : null}
{sku ? ( {sku ? (
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}> <Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
{sku} {sku}
@@ -83,6 +99,20 @@ export class ArticleTile extends Tile {
textAlign: 'left', textAlign: 'left',
}} }}
> >
{article?.brand?.name ? (
<BrandLabel
brand={article.brand}
name={article.brand.name}
size={14}
spacing={0.5}
typographyProps={{
variant: 'caption',
noWrap: true,
sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 },
}}
sx={{ mb: 0.25 }}
/>
) : null}
{label ? ( {label ? (
<Typography <Typography
variant="body2" variant="body2"

View File

@@ -30,6 +30,7 @@ import { preserveSearchFocus } from './searchFocus';
import { artikelDetailPath } from './articleRoute'; import { artikelDetailPath } from './articleRoute';
import withSearchParams from './withSearchParams'; import withSearchParams from './withSearchParams';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku } from '../../automats';
import { BrandLabel } from '../../components/brand';
const resultRowSx = { const resultRowSx = {
alignItems: 'center', alignItems: 'center',
@@ -429,7 +430,24 @@ class ArtikelSearchPage extends Component {
variant="body2" variant="body2"
color="text.secondary" color="text.secondary"
noWrap noWrap
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5, maxWidth: '100%' }}
> >
{(row.brand?.name || row.brandName) && (
<>
<BrandLabel
brand={row.brand || { name: row.brandName, thumbHash: row.brandThumbHash }}
name={row.brand?.name || row.brandName}
size={14}
spacing={0.5}
typographyProps={{
variant: 'body2',
color: 'text.secondary',
noWrap: true,
}}
/>
{' · '}
</>
)}
{row.sku} {row.sku}
{' · '} {' · '}
Bestand {formatStock(row.stock)} Bestand {formatStock(row.stock)}

View File

@@ -5,9 +5,11 @@ import CircularProgress from '@mui/material/CircularProgress';
import IconButton from '@mui/material/IconButton'; import IconButton from '@mui/material/IconButton';
import List from '@mui/material/List'; import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem'; import ListItem from '@mui/material/ListItem';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography'; import Typography from '@mui/material/Typography';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import CopyrightIcon from '@mui/icons-material/Copyright'; import CopyrightIcon from '@mui/icons-material/Copyright';
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
import BackendError from '../../components/BackendError'; import BackendError from '../../components/BackendError';
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit'; import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
import { PanelTile, PanelTileGrid } from 'dashgui/panels'; import { PanelTile, PanelTileGrid } from 'dashgui/panels';
@@ -16,6 +18,7 @@ import { notifyError } from '../../utils/notify';
import { setLastArticleSku } from '../../automats'; import { setLastArticleSku } from '../../automats';
import brandAutomat from '../../automats/brandAutomat'; import brandAutomat from '../../automats/brandAutomat';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { BrandThumb, BrandImageSearchDialog } from '../../components/brand';
function draftsFrom(brands) { function draftsFrom(brands) {
return Object.fromEntries(brands.map((b) => [b.id, b.name])); return Object.fromEntries(brands.map((b) => [b.id, b.name]));
@@ -32,6 +35,7 @@ class ArtikelMarkenPage extends PureComponent {
drafts: draftsFrom(initialBrands), drafts: draftsFrom(initialBrands),
newName: '', newName: '',
busyKey: null, busyKey: null,
searchBrand: null,
}; };
this._cancelled = false; this._cancelled = false;
this.handleCreate = this.handleCreate.bind(this); this.handleCreate = this.handleCreate.bind(this);
@@ -193,7 +197,11 @@ class ArtikelMarkenPage extends PureComponent {
disableGutters disableGutters
sx={{ gap: 1, py: 0.75, alignItems: 'center' }} sx={{ gap: 1, py: 0.75, alignItems: 'center' }}
> >
<CopyrightIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} /> <BrandThumb
brand={b}
size={22}
fallback={<CopyrightIcon sx={{ color: 'text.secondary', fontSize: 20, flexShrink: 0 }} />}
/>
<SingleLineTextEdit <SingleLineTextEdit
size="small" size="small"
label="" label=""
@@ -220,15 +228,31 @@ class ArtikelMarkenPage extends PureComponent {
}} }}
sx={{ flex: 1 }} sx={{ flex: 1 }}
/> />
<IconButton <Tooltip title="Logo suchen">
edge="end" <span>
size="small" <IconButton
aria-label={`„${b.name}" löschen`} size="small"
disabled={busy} aria-label={`Logo für „${b.name}" suchen`}
onClick={() => void this.handleDelete(b)} disabled={busy}
> onClick={() => this.setState({ searchBrand: b })}
<DeleteOutlinedIcon fontSize="small" /> >
</IconButton> <ImageSearchIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
<Tooltip title="Löschen">
<span>
<IconButton
edge="end"
size="small"
aria-label={`„${b.name}" löschen`}
disabled={busy}
onClick={() => void this.handleDelete(b)}
>
<DeleteOutlinedIcon fontSize="small" />
</IconButton>
</span>
</Tooltip>
</ListItem> </ListItem>
)) ))
)} )}
@@ -259,6 +283,12 @@ class ArtikelMarkenPage extends PureComponent {
</Button> </Button>
</Box> </Box>
</PanelTile> </PanelTile>
<BrandImageSearchDialog
open={Boolean(this.state.searchBrand)}
brand={this.state.searchBrand}
onClose={() => this.setState({ searchBrand: null })}
/>
</PanelTileGrid> </PanelTileGrid>
); );
} }

339
src/utils/cropLogo.js Normal file
View File

@@ -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<HTMLImageElement>}
*/
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<File>}
*/
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<File>}
*/
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');
}