u
This commit is contained in:
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';
|
||||
Reference in New Issue
Block a user