Files
dashfront/src/components/brand/BrandImageSearchDialog.jsx
2026-10-08 21:02:50 +02:00

664 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 AppDialog from '../AppDialog';
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 TextInput from '../TextInput';
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';
import {
CHECKERBOARD,
INFO_RIBBON,
SCRIM_LABEL,
VEIL_CARD_STRONG,
VEIL_OVERLAY,
WHITE,
} from '../../colors';
/**
* 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 (
<AppDialog
open={open}
onClose={processingId ? undefined : onClose}
maxWidth="md"
fullWidth
scroll="paper"
aria-labelledby="brand-image-search-dialog-title"
PaperProps={{
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
},
}}
slotProps={{
paper: {
sx: {
height: '80vh',
minHeight: '80vh',
maxHeight: '80vh',
},
},
}}
>
<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>
{/* Search bar */}
<Box
component="form"
onSubmit={(e) => {
e.preventDefault();
this.initSearch();
}}
sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}
>
<TextInput
fullWidth
value={query}
disabled={isSearching || Boolean(processingId)}
onChange={(e) => this.setState({ query: e.target.value })}
onKeyDown={this.handleQueryKeyDown}
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>
{/* Progress / Status banner */}
<Box
sx={{
minHeight: 28,
mb: 1.5,
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}
>
{status === 'loading' && (
<Box>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 0.5,
}}
>
<Typography variant="caption" color="text.secondary">
Bilder werden gesucht...
</Typography>
</Box>
<LinearProgress sx={{ borderRadius: 1 }} />
</Box>
)}
{status === 'streaming' && (
<Box>
<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>
)}
{status === 'done' && images.length > 0 && (
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Typography variant="caption" color="text.secondary">
Thumbnails geladen
</Typography>
<Typography variant="caption" fontWeight={600} color="text.secondary">
{loadedThumbs} / {images.length}
</Typography>
</Box>
)}
</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: WHITE,
backgroundImage: CHECKERBOARD,
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: SCRIM_LABEL,
color: WHITE,
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: INFO_RIBBON,
backdropFilter: 'blur(3px)',
color: WHITE,
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: VEIL_OVERLAY,
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: VEIL_CARD_STRONG,
boxShadow: 1,
'&:hover': { bgcolor: WHITE },
}}
>
<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: VEIL_CARD_STRONG,
boxShadow: 1,
'&:hover': { bgcolor: WHITE },
}}
>
<OpenInNewIcon sx={{ fontSize: 14, color: 'text.primary' }} />
</IconButton>
</Tooltip>
</Box>
)}
</Box>
);
})}
</Box>
)}
</DialogContent>
<DialogActions sx={{ px: 2.5, py: 1.5 }}>
<Button onClick={onClose} color="inherit">
Schließen
</Button>
</DialogActions>
</AppDialog>
);
}
}