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 ( Logo-Suche: {brand?.name || 'Marke'} {/* Search bar */} { e.preventDefault(); this.initSearch(); }} sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }} > this.setState({ query: e.target.value })} onKeyDown={this.handleQueryKeyDown} slotProps={{ input: { startAdornment: ( ), }, }} /> {/* Progress / Status banner */} {status === 'loading' && ( Bilder werden gesucht... )} {status === 'streaming' && ( Thumbnails werden gestreamt... {loadedThumbs} / {images.length} 0 ? Math.round((loadedThumbs / images.length) * 100) : 0 } sx={{ borderRadius: 1 }} /> )} {status === 'done' && images.length > 0 && ( Thumbnails geladen {loadedThumbs} / {images.length} )} {/* Error banner */} {error && ( {error} {isSerperKeyError && ( Hinweis: Sie können Ihren Serper API-Key im Hauptmenü unter{' '} Firmeneinstellungen → Einstellungen (Serper API-Key) hinterlegen. )} )} {/* Empty state when done */} {status === 'done' && images.length === 0 && !error && ( Keine Bilder zu „{query}“ gefunden. )} {/* Image Grid */} {images.length > 0 && ( {images.map((img) => { const isThisProcessing = processingId === img.id; const isOtherProcessing = Boolean(processingId) && !isThisProcessing; return ( this.handleSelectImage(img)} sx={{ position: 'relative', aspectRatio: '1 / 1', borderRadius: 1.5, overflow: 'hidden', border: '1px solid', borderColor: isThisProcessing ? 'primary.main' : 'divider', bgcolor: 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 ? ( ) : img.failed ? ( ) : ( )} {/* Dimensions badge */} {img.width && img.height ? ( ) : null} {/* Quick selection hover pill */} {!processingId && ( Als Logo wählen )} {/* Processing overlay for selected card */} {isThisProcessing && ( {processingStep || 'Wird freigestellt...'} )} {/* Hover action overlay (open / copy url) */} {img.url && !processingId && ( this.handleCopyUrl(img.url, e)} sx={{ p: 0.5, bgcolor: VEIL_CARD_STRONG, boxShadow: 1, '&:hover': { bgcolor: WHITE }, }} > this.handleOpenUrl(img.url, e)} sx={{ p: 0.5, bgcolor: VEIL_CARD_STRONG, boxShadow: 1, '&:hover': { bgcolor: WHITE }, }} > )} ); })} )} ); } }