u
This commit is contained in:
51
eslint.config.js
Normal file
51
eslint.config.js
Normal file
@@ -0,0 +1,51 @@
|
||||
import js from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'dist/**',
|
||||
'dist-ssr/**',
|
||||
'node_modules/**',
|
||||
'certs/**',
|
||||
'tmp/**',
|
||||
'.dashfront-token',
|
||||
],
|
||||
},
|
||||
js.configs.recommended,
|
||||
{
|
||||
files: ['**/*.{js,jsx,mjs}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
globals: {
|
||||
...globals.browser,
|
||||
...globals.node,
|
||||
React: 'readonly',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
},
|
||||
rules: {
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'warn',
|
||||
'no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^(React|_)',
|
||||
ignoreRestSiblings: true,
|
||||
},
|
||||
],
|
||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||
'no-debugger': 'warn',
|
||||
},
|
||||
},
|
||||
];
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -28,9 +28,12 @@
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vite": "^8.3.0",
|
||||
"vite": "^8.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
},
|
||||
@@ -1858,6 +1861,27 @@
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
|
||||
"integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://eslint.org/donate"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^10.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"eslint": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/object-schema": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
|
||||
@@ -4206,6 +4230,26 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-react-hooks": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
|
||||
"integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-scope": {
|
||||
"version": "9.1.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
|
||||
@@ -4694,6 +4738,19 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.12.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.12.0.tgz",
|
||||
"integrity": "sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/globalthis": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz",
|
||||
@@ -4835,6 +4892,23 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/hermes-estree": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
|
||||
"integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/hermes-parser": {
|
||||
"version": "0.25.1",
|
||||
"resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
|
||||
"integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"hermes-estree": "0.25.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
@@ -7461,16 +7535,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
|
||||
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
|
||||
"version": "8.3.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.1.tgz",
|
||||
"integrity": "sha512-/bvH9E9tmCXRGp2uXY3WbOldqpTwFkbha/8ANaEQ6VkxhH60KyqLwgZq6lG2y+4uT55x9+9eUHMpQ7uGnOCKjA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.28",
|
||||
"rolldown": "~1.2.6",
|
||||
"rolldown": "~1.2.9",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -8099,6 +8173,29 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.6.5",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
|
||||
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-validation-error": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
|
||||
"integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"@mui/material": "^9.4.0",
|
||||
"@shopify/draggable": "^1.2.1",
|
||||
"dashgui": "file:../dashgui",
|
||||
"eslint": "^10.11.0",
|
||||
"jsbarcode": "^3.12.3",
|
||||
"notistack": "^3.0.2",
|
||||
"pdfconvert-wasm": "^0.3.0",
|
||||
@@ -35,9 +34,13 @@
|
||||
"thumbhash": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@vitejs/plugin-react": "6.1.1",
|
||||
"eslint": "^10.11.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"globals": "^17.12.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"vite": "^8.3.0",
|
||||
"vite": "^8.3.1",
|
||||
"vite-plugin-pwa": "^1.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
775
src/components/article/ArticleImageSearchDialog.jsx
Normal file
775
src/components/article/ArticleImageSearchDialog.jsx
Normal file
@@ -0,0 +1,775 @@
|
||||
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 AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { serper } from '../../api';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
import { articleWithUploadedPhoto } from '../../utils/articleDetail';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
|
||||
/**
|
||||
* Downloads image as a Blob, trying full resolution with CORS first,
|
||||
* falling back to canvas crossOrigin draw, and finally the Serper SSE dataUrl.
|
||||
* @param {string} url
|
||||
* @param {string|null} fallbackDataUrl
|
||||
* @returns {Promise<Blob>}
|
||||
*/
|
||||
async function downloadImageBlob(url, fallbackDataUrl) {
|
||||
// Strategy 1: Direct fetch with CORS
|
||||
if (url) {
|
||||
try {
|
||||
const resp = await fetch(url, { mode: 'cors' });
|
||||
if (resp.ok) {
|
||||
const blob = await resp.blob();
|
||||
if (blob && blob.type.startsWith('image/')) {
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// CORS or network failure
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Attempt loading with HTMLImageElement crossOrigin
|
||||
if (url) {
|
||||
try {
|
||||
const blob = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = 'anonymous';
|
||||
img.onload = () => {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth || img.width;
|
||||
canvas.height = img.naturalHeight || img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
reject(new Error('Canvas 2D-Kontext nicht verfügbar'));
|
||||
return;
|
||||
}
|
||||
ctx.drawImage(img, 0, 0);
|
||||
canvas.toBlob((b) => {
|
||||
if (b) resolve(b);
|
||||
else reject(new Error('Canvas toBlob fehlgeschlagen'));
|
||||
}, 'image/png');
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
}
|
||||
};
|
||||
img.onerror = reject;
|
||||
img.src = url;
|
||||
});
|
||||
if (blob) return blob;
|
||||
} catch {
|
||||
// Canvas tainted or image load failed
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Streamed WebP/JPEG dataUrl from Serper SSE
|
||||
if (fallbackDataUrl) {
|
||||
const resp = await fetch(fallbackDataUrl);
|
||||
const blob = await resp.blob();
|
||||
if (blob) return blob;
|
||||
}
|
||||
|
||||
throw new Error('Das Bild konnte nicht heruntergeladen werden (CORS- oder Netzwerkfehler).');
|
||||
}
|
||||
|
||||
function computeDefaultQuery(article) {
|
||||
if (!article) return '';
|
||||
const brandName = article.brand?.name || (typeof article.brand === 'string' ? article.brand : '');
|
||||
const articleName = String(article.name ?? '').trim();
|
||||
if (brandName && articleName) {
|
||||
if (!articleName.toLowerCase().includes(brandName.toLowerCase())) {
|
||||
return `${brandName} ${articleName}`.trim();
|
||||
}
|
||||
return articleName;
|
||||
}
|
||||
return articleName || String(article.sku ?? '').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog that searches Google Images via Serper.dev SSE stream for article photos.
|
||||
*/
|
||||
export default class ArticleImageSearchDialog extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
query: computeDefaultQuery(props.article),
|
||||
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 nextQuery = computeDefaultQuery(this.props.article);
|
||||
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, preferSource: true },
|
||||
{ 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 { article: articleData, onClose, onArticleUpdated } = this.props;
|
||||
if (!articleData?.sku) {
|
||||
enqueueSnackbar('Kein Artikel ausgewählt.', { variant: 'error' });
|
||||
return;
|
||||
}
|
||||
if (this.state.processingId) return;
|
||||
|
||||
this.setState({
|
||||
processingId: img.id,
|
||||
processingStep: 'Herunterladen...',
|
||||
});
|
||||
|
||||
try {
|
||||
// 1. Download image as Blob
|
||||
const blob = await downloadImageBlob(img.url, img.dataUrl);
|
||||
|
||||
// 2. Prepare article photo (scale to max 1000px, encode to AVIF)
|
||||
this.setState({ processingStep: 'In AVIF konvertieren...' });
|
||||
const file = await prepareArticlePhoto(blob);
|
||||
|
||||
// 3. Upload to backend
|
||||
this.setState({ processingStep: 'Bild wird gespeichert...' });
|
||||
const res = await this.context.article.addImage(articleData.sku, file);
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.image, 'image');
|
||||
|
||||
const updated = articleWithUploadedPhoto(articleData, res.image);
|
||||
artikelAutomat.actions.setArticle(updated);
|
||||
onArticleUpdated?.(updated);
|
||||
|
||||
enqueueSnackbar('Bild erfolgreich hinzugefügt.', { variant: 'success' });
|
||||
|
||||
if (onClose) {
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to process and upload article photo:', err);
|
||||
enqueueSnackbar(`Fehler beim Speichern: ${err?.message || err}`, { variant: 'error' });
|
||||
this.setState({ processingId: null, processingStep: '' });
|
||||
}
|
||||
};
|
||||
|
||||
renderSuggestionChips() {
|
||||
const { article } = this.props;
|
||||
const { query, status, processingId } = this.state;
|
||||
if (!article) return null;
|
||||
|
||||
const brandName = article.brand?.name || (typeof article.brand === 'string' ? article.brand : '');
|
||||
const articleName = String(article.name ?? '').trim();
|
||||
const sku = String(article.sku ?? '').trim();
|
||||
const firstEan = Array.isArray(article.eans) && article.eans.length > 0 ? String(article.eans[0]).trim() : '';
|
||||
|
||||
const suggestions = [];
|
||||
|
||||
if (articleName && articleName !== query) {
|
||||
suggestions.push({ label: articleName, value: articleName });
|
||||
}
|
||||
if (brandName && articleName && `${brandName} ${articleName}` !== query) {
|
||||
suggestions.push({ label: `${brandName} ${articleName}`, value: `${brandName} ${articleName}` });
|
||||
}
|
||||
if (sku && sku !== query && sku !== articleName) {
|
||||
suggestions.push({ label: `SKU: ${sku}`, value: sku });
|
||||
}
|
||||
if (firstEan && firstEan !== query) {
|
||||
suggestions.push({ label: `EAN: ${firstEan}`, value: firstEan });
|
||||
}
|
||||
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
const disabled = status === 'loading' || status === 'streaming' || Boolean(processingId);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.75, mb: 1.5 }}>
|
||||
{suggestions.map((s) => (
|
||||
<Chip
|
||||
key={s.label}
|
||||
size="small"
|
||||
label={s.label}
|
||||
variant="outlined"
|
||||
clickable={!disabled}
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
this.setState({ query: s.value }, () => this.initSearch());
|
||||
}}
|
||||
sx={{ fontSize: '0.75rem' }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { open, onClose, article } = 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="article-image-search-dialog-title"
|
||||
>
|
||||
<DialogTitle
|
||||
id="article-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 }}>
|
||||
Bildsuche: {article?.name || article?.sku || 'Artikel'}
|
||||
</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 }}
|
||||
>
|
||||
<TextField
|
||||
size="small"
|
||||
fullWidth
|
||||
value={query}
|
||||
disabled={isSearching || Boolean(processingId)}
|
||||
onChange={(e) => this.setState({ query: e.target.value })}
|
||||
onKeyDown={this.handleQueryKeyDown}
|
||||
placeholder="z. B. Produktname, Marke oder EAN"
|
||||
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>
|
||||
|
||||
{/* Search suggestions */}
|
||||
{this.renderSuggestionChips()}
|
||||
|
||||
{/* 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',
|
||||
}}
|
||||
>
|
||||
<AddPhotoAlternateIcon color="primary" sx={{ fontSize: 17 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Klicken Sie auf ein Bild, um es herunterzuladen, als AVIF zu optimieren und zum Artikel hinzuzufügen.
|
||||
</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">
|
||||
Bilder werden geladen...
|
||||
</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={article?.name || 'Bild'}
|
||||
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 }}
|
||||
>
|
||||
Bild hinzufügen
|
||||
</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,
|
||||
color: 'primary.main',
|
||||
fontSize: '0.72rem',
|
||||
display: 'block',
|
||||
}}
|
||||
>
|
||||
{processingStep || 'Verarbeite Bild...'}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Quick actions top-right overlay on hover */}
|
||||
{!processingId && (
|
||||
<Box
|
||||
className="image-overlay-actions"
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
display: 'flex',
|
||||
gap: 0.5,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<Tooltip title="Bild-URL kopieren" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => this.handleCopyUrl(img.url, e)}
|
||||
sx={{
|
||||
p: 0.4,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
boxShadow: 1,
|
||||
'&:hover': { bgcolor: '#fff' },
|
||||
}}
|
||||
>
|
||||
<ContentCopyIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="In neuem Tab öffnen" arrow>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(e) => this.handleOpenUrl(img.url, e)}
|
||||
sx={{
|
||||
p: 0.4,
|
||||
bgcolor: 'rgba(255, 255, 255, 0.85)',
|
||||
backdropFilter: 'blur(2px)',
|
||||
boxShadow: 1,
|
||||
'&:hover': { bgcolor: '#fff' },
|
||||
}}
|
||||
>
|
||||
<OpenInNewIcon sx={{ fontSize: 13, color: 'text.secondary' }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 2.5, py: 1.5, justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
{images.length > 0 && (
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
{images.length} Ergebnisse
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={Boolean(processingId)}
|
||||
color="inherit"
|
||||
>
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +1,16 @@
|
||||
import { PureComponent, createRef } from 'react';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import { PureComponent } from 'react';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import { PanelTile } from 'dashgui/panels';
|
||||
import { requireObject } from '../../utils/apiShape';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
|
||||
import { primaryPhoto } from '../../utils/articleDetail';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import ArticlePhotosDialog from './ArticlePhotosDialog';
|
||||
|
||||
class ArticlePhotoTile extends PureComponent {
|
||||
static contextType = StorageManagerContext;
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileInputRef = createRef();
|
||||
this.state = { busy: false, editOpen: false };
|
||||
this.handlePasteClick = this.handlePasteClick.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.state = { editOpen: false };
|
||||
this.handleEditOpen = this.handleEditOpen.bind(this);
|
||||
this.handleEditClose = this.handleEditClose.bind(this);
|
||||
this.uploadRawImage = this.uploadRawImage.bind(this);
|
||||
}
|
||||
|
||||
async uploadRawImage(rawBlob) {
|
||||
const { article: articleData, onArticleUpdated } = this.props;
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const uploadStarted = performance.now();
|
||||
const res = await this.context.article.addImage(articleData.sku, file);
|
||||
console.log('[article-photo] upload', {
|
||||
uploadMs: Math.round(performance.now() - uploadStarted),
|
||||
bytes: file.size,
|
||||
sku: articleData.sku,
|
||||
});
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.image, 'image');
|
||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
this.setState({ busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
handlePasteClick() {
|
||||
if (this.state.busy) return;
|
||||
void pasteImageOrFallback(() => {
|
||||
this.fileInputRef.current?.click();
|
||||
}).then((image) => {
|
||||
if (image) void this.uploadRawImage(image);
|
||||
});
|
||||
}
|
||||
|
||||
handleFileChange(event) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = '';
|
||||
if (!file) return;
|
||||
void this.uploadRawImage(file);
|
||||
}
|
||||
|
||||
handleEditOpen() {
|
||||
@@ -80,9 +30,8 @@ class ArticlePhotoTile extends PureComponent {
|
||||
sx,
|
||||
color,
|
||||
} = this.props;
|
||||
const { busy, editOpen } = this.state;
|
||||
const { editOpen } = this.state;
|
||||
const photo = primaryPhoto(articleData);
|
||||
const hasPhoto = photo?.thumbHash != null || photo?.fullHash != null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -91,31 +40,15 @@ class ArticlePhotoTile extends PureComponent {
|
||||
minHeight={minHeight}
|
||||
sx={sx}
|
||||
color={color}
|
||||
action={
|
||||
hasPhoto
|
||||
? {
|
||||
label: 'Bilder bearbeiten',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleEditOpen,
|
||||
busy: false,
|
||||
}
|
||||
: {
|
||||
label: 'Bild einfügen',
|
||||
icon: ContentPasteIcon,
|
||||
onClick: this.handlePasteClick,
|
||||
busy,
|
||||
}
|
||||
}
|
||||
action={{
|
||||
label: 'Bilder bearbeiten',
|
||||
icon: EditOutlinedIcon,
|
||||
onClick: this.handleEditOpen,
|
||||
busy: false,
|
||||
}}
|
||||
>
|
||||
<ArticlePhoto photo={photo} />
|
||||
</PanelTile>
|
||||
<input
|
||||
ref={this.fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
<ArticlePhotosDialog
|
||||
open={editOpen}
|
||||
article={articleData}
|
||||
@@ -128,3 +61,4 @@ class ArticlePhotoTile extends PureComponent {
|
||||
}
|
||||
|
||||
export default ArticlePhotoTile;
|
||||
|
||||
|
||||
@@ -8,10 +8,14 @@ import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import IconButton from '@mui/material/IconButton';
|
||||
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import ImageSearchIcon from '@mui/icons-material/ImageSearch';
|
||||
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
import { StorageManagerContext } from '../../api/storageManagerContext';
|
||||
import artikelAutomat from '../../automats/artikelAutomat';
|
||||
import { requireArray, requireObject } from '../../utils/apiShape';
|
||||
import {
|
||||
articleWithPhotoOrder,
|
||||
@@ -22,9 +26,10 @@ import {
|
||||
sortedPhotos,
|
||||
} from '../../utils/articleDetail';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
import { pasteImageOrFallback } from '../../utils/pasteImage';
|
||||
import { readClipboardImage } from '../../utils/pasteImage';
|
||||
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
|
||||
import ArticlePhoto from './ArticlePhoto';
|
||||
import ArticleImageSearchDialog from './ArticleImageSearchDialog';
|
||||
|
||||
const overlayChipSx = {
|
||||
height: 22,
|
||||
@@ -46,9 +51,12 @@ class ArticlePhotosDialog extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.fileInputRef = createRef();
|
||||
this.state = { busy: false };
|
||||
this.state = { busy: false, searchOpen: false };
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
this.handlePasteClick = this.handlePasteClick.bind(this);
|
||||
this.handleUploadClick = this.handleUploadClick.bind(this);
|
||||
this.handleSearchOpen = this.handleSearchOpen.bind(this);
|
||||
this.handleSearchClose = this.handleSearchClose.bind(this);
|
||||
this.handleFileChange = this.handleFileChange.bind(this);
|
||||
this.uploadRawImage = this.uploadRawImage.bind(this);
|
||||
}
|
||||
@@ -72,7 +80,9 @@ class ArticlePhotosDialog extends Component {
|
||||
});
|
||||
requireObject(res, 'POST /api/article/:sku/image');
|
||||
requireObject(res.image, 'image');
|
||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||
const updated = articleWithUploadedPhoto(articleData, res.image);
|
||||
artikelAutomat.actions.setArticle(updated);
|
||||
onArticleUpdated?.(updated);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
@@ -80,13 +90,33 @@ class ArticlePhotosDialog extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handlePasteClick() {
|
||||
async handlePasteClick() {
|
||||
if (this.state.busy) return;
|
||||
void pasteImageOrFallback(() => {
|
||||
this.fileInputRef.current?.click();
|
||||
}).then((image) => {
|
||||
if (image) void this.uploadRawImage(image);
|
||||
});
|
||||
try {
|
||||
const image = await readClipboardImage();
|
||||
if (image) {
|
||||
await this.uploadRawImage(image);
|
||||
} else {
|
||||
enqueueSnackbar('Kein Bild in der Zwischenablage gefunden.', { variant: 'info' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Clipboard read error:', err);
|
||||
enqueueSnackbar('Zugriff auf Zwischenablage nicht möglich.', { variant: 'warning' });
|
||||
}
|
||||
}
|
||||
|
||||
handleUploadClick() {
|
||||
if (this.state.busy) return;
|
||||
this.fileInputRef.current?.click();
|
||||
}
|
||||
|
||||
handleSearchOpen() {
|
||||
if (this.state.busy) return;
|
||||
this.setState({ searchOpen: true });
|
||||
}
|
||||
|
||||
handleSearchClose() {
|
||||
this.setState({ searchOpen: false });
|
||||
}
|
||||
|
||||
handleFileChange(event) {
|
||||
@@ -101,7 +131,9 @@ class ArticlePhotosDialog extends Component {
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
await this.context.article.deleteImage(articleData.sku, fullHash);
|
||||
onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash));
|
||||
const updated = articleWithoutPhoto(articleData, fullHash);
|
||||
artikelAutomat.actions.setArticle(updated);
|
||||
onArticleUpdated?.(updated);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
@@ -126,7 +158,9 @@ class ArticlePhotosDialog extends Component {
|
||||
const res = await this.context.article.orderImages(articleData.sku, { fullHashes });
|
||||
requireObject(res, 'PUT /api/article/:sku/images/order');
|
||||
requireArray(res.fullHashes, 'fullHashes');
|
||||
onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes));
|
||||
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
|
||||
artikelAutomat.actions.setArticle(updated);
|
||||
onArticleUpdated?.(updated);
|
||||
} catch (err) {
|
||||
notifyError(err);
|
||||
} finally {
|
||||
@@ -250,16 +284,37 @@ class ArticlePhotosDialog extends Component {
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between' }}>
|
||||
<Button
|
||||
type="button"
|
||||
startIcon={<ContentPasteIcon />}
|
||||
onClick={this.handlePasteClick}
|
||||
disabled={busy}
|
||||
>
|
||||
Bild einfügen
|
||||
</Button>
|
||||
<Button type="button" onClick={this.handleClose} disabled={busy}>
|
||||
<DialogActions sx={{ px: 3, py: 2, justifyContent: 'space-between', flexWrap: 'wrap', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
startIcon={<ContentPasteIcon />}
|
||||
onClick={this.handlePasteClick}
|
||||
disabled={busy}
|
||||
>
|
||||
Bild einfügen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
startIcon={<UploadFileIcon />}
|
||||
onClick={this.handleUploadClick}
|
||||
disabled={busy}
|
||||
>
|
||||
Bild hochladen
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="contained"
|
||||
startIcon={<ImageSearchIcon />}
|
||||
onClick={this.handleSearchOpen}
|
||||
disabled={busy}
|
||||
>
|
||||
Bild suchen
|
||||
</Button>
|
||||
</Box>
|
||||
<Button type="button" onClick={this.handleClose} disabled={busy} color="inherit">
|
||||
Schließen
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -271,6 +326,12 @@ class ArticlePhotosDialog extends Component {
|
||||
hidden
|
||||
onChange={this.handleFileChange}
|
||||
/>
|
||||
<ArticleImageSearchDialog
|
||||
open={this.state.searchOpen}
|
||||
article={articleData}
|
||||
onClose={this.handleSearchClose}
|
||||
onArticleUpdated={this.props.onArticleUpdated}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export { default as ArticleView } from './ArticleView';
|
||||
export { default as ArticleNameDialog } from './ArticleNameDialog';
|
||||
export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
|
||||
export { default as ArticleImageSearchDialog } from './ArticleImageSearchDialog';
|
||||
export { default as ArticleTileTab } from './ArticleTileTab';
|
||||
export { default as NewArticleTile } from './NewArticleTile';
|
||||
|
||||
@@ -446,6 +446,7 @@ class ManifestForm extends PureComponent {
|
||||
partyId,
|
||||
number,
|
||||
externalNumber,
|
||||
lines,
|
||||
saving,
|
||||
pdfUploading,
|
||||
pdfDialogOpen,
|
||||
|
||||
@@ -17,7 +17,7 @@ import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutli
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import LinkOffOutlinedIcon from '@mui/icons-material/LinkOffOutlined';
|
||||
import { getFirstVal, parseNum } from './manifestConfig';
|
||||
import { getFirstVal, parseNum, LINE_COLUMNS } from './manifestConfig';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { primaryPrice, priceBrutto } from '../../utils/articleDetail';
|
||||
|
||||
@@ -30,7 +30,7 @@ import { requireArray, requireObject } from '../../utils/apiShape';
|
||||
import { backendErrorFrom } from '../../utils/backendError';
|
||||
import { notifyError } from '../../utils/notify';
|
||||
|
||||
const PRINTER_TYPES = ['esc/pos', 'zpl'];
|
||||
const PRINTER_TYPES = ['esc/pos', 'zpl', 'ipp'];
|
||||
|
||||
const EMPTY_FORM = {
|
||||
address: '',
|
||||
@@ -458,7 +458,7 @@ export default class PrinterSettingsPanel extends Component {
|
||||
|
||||
<Divider sx={{ my: 2 }} />
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl.
|
||||
Reihenfolge durch Ziehen ändern. Unterstützte Typen: esc/pos, zpl, ipp.
|
||||
</Typography>
|
||||
|
||||
<PrinterFormDialog
|
||||
|
||||
@@ -9,6 +9,7 @@ import Paper from '@mui/material/Paper';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import ClearIcon from '@mui/icons-material/Clear';
|
||||
import { StorageManagerContext, getStorageManager } from '../../api/storageManagerContext';
|
||||
import BackendError from '../../components/BackendError';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
|
||||
@@ -113,7 +113,7 @@ export function autoCropWhiteAndTransparent(img) {
|
||||
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));
|
||||
throw new Error('Canvas durch CORS blockiert: ' + (err?.message || err), { cause: err });
|
||||
}
|
||||
|
||||
const data = imgData.data;
|
||||
|
||||
@@ -87,7 +87,7 @@ export async function fetchDeliveryLedgerSummary(deliveryId, { deliveryApi = def
|
||||
const totalQuantity = linesWithLedger.reduce((sum, l) => sum + l.targetAmount, 0);
|
||||
const bookedQuantity = linesWithLedger.reduce((sum, l) => sum + l.bookedAmount, 0);
|
||||
|
||||
let status = 'unbooked';
|
||||
let status;
|
||||
if (totalPositions === 0) {
|
||||
status = 'empty';
|
||||
} else if (bookedPositions === totalPositions && bookedQuantity >= totalQuantity) {
|
||||
|
||||
Reference in New Issue
Block a user