u
This commit is contained in:
126
src/api/index.js
126
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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open onClose={this.handleClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>
|
||||
<Dialog
|
||||
open
|
||||
onClose={this.handleClose}
|
||||
maxWidth={hasCompanies ? 'sm' : 'md'}
|
||||
fullWidth
|
||||
>
|
||||
<DialogTitle sx={{ pb: 1 }}>
|
||||
{loading
|
||||
? 'Unternehmen'
|
||||
: hasCompanies
|
||||
? 'Unternehmen auswählen'
|
||||
: 'Unternehmen anlegen'}
|
||||
: 'Willkommen bei Dash'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{!loading ? (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2.5 }}>
|
||||
{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.'}
|
||||
</Typography>
|
||||
) : null}
|
||||
|
||||
@@ -191,7 +327,7 @@ class CompanySetupDialog extends Component {
|
||||
{hasCompanies ? <Divider>oder</Divider> : null}
|
||||
|
||||
<Box component="form" onSubmit={this.handleCreate} noValidate>
|
||||
<Stack spacing={2}>
|
||||
<Stack spacing={2.5}>
|
||||
{hasCompanies ? (
|
||||
<Typography variant="subtitle2">Neues Unternehmen anlegen</Typography>
|
||||
) : 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 {
|
||||
</ToggleButtonGroup>
|
||||
</FormControl>
|
||||
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<TextField
|
||||
fullWidth
|
||||
required
|
||||
@@ -262,8 +398,143 @@ class CompanySetupDialog extends Component {
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Button type="submit" variant="contained" disabled={busy || !canCreate}>
|
||||
Anlegen
|
||||
{/* Nummernkreise Section (when no company was created yet or creating new) */}
|
||||
<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>
|
||||
</Stack>
|
||||
</Box>
|
||||
@@ -287,3 +558,4 @@ class CompanySetupDialog extends Component {
|
||||
}
|
||||
|
||||
export default CompanySetupDialog;
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
|
||||
@@ -7,6 +7,9 @@ import { EditablePanelTile } from 'dashgui/panels';
|
||||
import { StorageManagerContext } from '../../../api/storageManagerContext';
|
||||
import { requireDefined, requireObject } from '../../../utils/apiShape';
|
||||
import { notifyError } from '../../../utils/notify';
|
||||
import InputAdornment from '@mui/material/InputAdornment';
|
||||
import { BrandLabel, BrandThumb } from '../../brand';
|
||||
|
||||
class PanelValue extends Component {
|
||||
render() {
|
||||
const { label, children } = this.props;
|
||||
@@ -27,7 +30,12 @@ class PanelValue extends Component {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -43,6 +51,11 @@ const SECOND_ROW_MIN_HEIGHT = 220;
|
||||
class BrandEdit extends Component {
|
||||
render() {
|
||||
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 (
|
||||
<Autocomplete
|
||||
fullWidth
|
||||
@@ -57,6 +70,21 @@ class BrandEdit extends Component {
|
||||
onChange={(_event, option) => {
|
||||
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) => (
|
||||
<TextField
|
||||
{...params}
|
||||
@@ -65,6 +93,16 @@ class BrandEdit extends Component {
|
||||
size="small"
|
||||
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: {
|
||||
...params.slotProps?.inputLabel,
|
||||
shrink: true,
|
||||
@@ -152,8 +190,19 @@ export default class ArticleViewBrand extends PureComponent {
|
||||
onSave={this.handleSave}
|
||||
onError={notifyError}
|
||||
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}
|
||||
display={<PanelValue>{value.name || null}</PanelValue>}
|
||||
edit={({ draft, onChange, inputRef }) => (
|
||||
<BrandEdit
|
||||
brands={brands}
|
||||
|
||||
623
src/components/brand/BrandImageSearchDialog.jsx
Normal file
623
src/components/brand/BrandImageSearchDialog.jsx
Normal 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ß- & 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
53
src/components/brand/BrandLabel.jsx
Normal file
53
src/components/brand/BrandLabel.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/components/brand/BrandThumb.jsx
Normal file
112
src/components/brand/BrandThumb.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
3
src/components/brand/index.js
Normal file
3
src/components/brand/index.js
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as BrandThumb, extractBrandThumbInfo } from './BrandThumb';
|
||||
export { default as BrandLabel } from './BrandLabel';
|
||||
export { default as BrandImageSearchDialog } from './BrandImageSearchDialog';
|
||||
@@ -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 {
|
||||
</Box>
|
||||
}
|
||||
secondary={
|
||||
primaryEan ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
EAN: {primaryEan}
|
||||
</Typography>
|
||||
) : null
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mt: 0.25 }}>
|
||||
{(article.brand?.name || article.brandName) && (
|
||||
<BrandLabel
|
||||
brand={article.brand || { name: article.brandName, thumbHash: article.brandThumbHash }}
|
||||
name={article.brand?.name || article.brandName}
|
||||
size={14}
|
||||
typographyProps={{
|
||||
variant: 'caption',
|
||||
color: 'text.secondary',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{primaryEan ? (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }}
|
||||
>
|
||||
EAN: {primaryEan}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -23,6 +23,7 @@ import { formatPrice } from '../../utils/price';
|
||||
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||
import artikelAutomat, { createArticleSelector, getArticle } from '../../automats/artikelAutomat';
|
||||
import { artikelDetailPath } from '../../pages/artikel/articleRoute';
|
||||
import { BrandLabel } from '../brand';
|
||||
|
||||
class ManifestFormLine extends PureComponent {
|
||||
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 */}
|
||||
{(fullArticle.name || article.name) && (
|
||||
<Typography
|
||||
|
||||
@@ -27,12 +27,14 @@ const SETTABLE_KEYS = [
|
||||
'ui.theme',
|
||||
'keys.tavily',
|
||||
'keys.openrouter',
|
||||
'keys.serper',
|
||||
];
|
||||
|
||||
const KEY_LABELS = {
|
||||
'ui.theme': 'UI-Theme',
|
||||
'keys.tavily': 'Tavily API-Key',
|
||||
'keys.openrouter': 'OpenRouter API-Key',
|
||||
'keys.serper': 'Serper API-Key',
|
||||
};
|
||||
|
||||
function keyLabel(key) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Tile, tileBaseSx } from 'dashgui/tiles';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { BrandLabel } from '../brand';
|
||||
|
||||
export class ArticleTile extends Tile {
|
||||
hasPhoto() {
|
||||
@@ -21,8 +22,23 @@ export class ArticleTile extends Tile {
|
||||
const { article } = this.props;
|
||||
if (!article) return null;
|
||||
const sku = articleSku(article);
|
||||
const brand = article.brand;
|
||||
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 ? (
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
||||
{sku}
|
||||
@@ -83,6 +99,20 @@ export class ArticleTile extends Tile {
|
||||
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 ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { preserveSearchFocus } from './searchFocus';
|
||||
import { artikelDetailPath } from './articleRoute';
|
||||
import withSearchParams from './withSearchParams';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import { BrandLabel } from '../../components/brand';
|
||||
|
||||
const resultRowSx = {
|
||||
alignItems: 'center',
|
||||
@@ -429,7 +430,24 @@ class ArtikelSearchPage extends Component {
|
||||
variant="body2"
|
||||
color="text.secondary"
|
||||
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}
|
||||
{' · '}
|
||||
Bestand {formatStock(row.stock)}
|
||||
|
||||
@@ -5,9 +5,11 @@ import CircularProgress from '@mui/material/CircularProgress';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import Tooltip from '@mui/material/Tooltip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
|
||||
import CopyrightIcon from '@mui/icons-material/Copyright';
|
||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import SingleLineTextEdit from 'dashgui/SingleLineTextEdit';
|
||||
import { PanelTile, PanelTileGrid } from 'dashgui/panels';
|
||||
@@ -16,6 +18,7 @@ import { notifyError } from '../../utils/notify';
|
||||
import { setLastArticleSku } from '../../automats';
|
||||
import brandAutomat from '../../automats/brandAutomat';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { BrandThumb, BrandImageSearchDialog } from '../../components/brand';
|
||||
|
||||
function draftsFrom(brands) {
|
||||
return Object.fromEntries(brands.map((b) => [b.id, b.name]));
|
||||
@@ -32,6 +35,7 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
drafts: draftsFrom(initialBrands),
|
||||
newName: '',
|
||||
busyKey: null,
|
||||
searchBrand: null,
|
||||
};
|
||||
this._cancelled = false;
|
||||
this.handleCreate = this.handleCreate.bind(this);
|
||||
@@ -193,7 +197,11 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
disableGutters
|
||||
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
|
||||
size="small"
|
||||
label=""
|
||||
@@ -220,15 +228,31 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
}}
|
||||
sx={{ flex: 1 }}
|
||||
/>
|
||||
<IconButton
|
||||
edge="end"
|
||||
size="small"
|
||||
aria-label={`„${b.name}" löschen`}
|
||||
disabled={busy}
|
||||
onClick={() => void this.handleDelete(b)}
|
||||
>
|
||||
<DeleteOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<Tooltip title="Logo suchen">
|
||||
<span>
|
||||
<IconButton
|
||||
size="small"
|
||||
aria-label={`Logo für „${b.name}" suchen`}
|
||||
disabled={busy}
|
||||
onClick={() => this.setState({ searchBrand: b })}
|
||||
>
|
||||
<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>
|
||||
))
|
||||
)}
|
||||
@@ -259,6 +283,12 @@ class ArtikelMarkenPage extends PureComponent {
|
||||
</Button>
|
||||
</Box>
|
||||
</PanelTile>
|
||||
|
||||
<BrandImageSearchDialog
|
||||
open={Boolean(this.state.searchBrand)}
|
||||
brand={this.state.searchBrand}
|
||||
onClose={() => this.setState({ searchBrand: null })}
|
||||
/>
|
||||
</PanelTileGrid>
|
||||
);
|
||||
}
|
||||
|
||||
339
src/utils/cropLogo.js
Normal file
339
src/utils/cropLogo.js
Normal 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');
|
||||
}
|
||||
Reference in New Issue
Block a user