Files
dashfront/src/components/article/ArticlePhotosDialog.jsx
2026-10-07 19:33:26 +02:00

375 lines
12 KiB
JavaScript

import { Component, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
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 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 EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { enqueueSnackbar } from 'notistack';
import { article as articleApi } from '../../api';
import artikelAutomat from '../../automats/artikelAutomat';
import chatAutomat from '../../automats/chatAutomat';
import { requireArray, requireObject } from '../../utils/apiShape';
import {
articleWithPhotoOrder,
articleWithUploadedPhoto,
articleWithoutPhoto,
formatPhotoDimensions,
formatPhotoSizeKb,
sortedPhotos,
} from '../../utils/articleDetail';
import { notifyError } from '../../utils/notify';
import { readClipboardImage } from '../../utils/pasteImage';
import {
SCRIM_BUTTON,
SCRIM_LABEL,
SCRIM_SOFT,
SCRIM_STRONG,
VEIL_MUTED,
WHITE,
} from '../../colors';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import ArticlePhoto from './ArticlePhoto';
import ArticleImageSearchDialog from './ArticleImageSearchDialog';
const overlayChipSx = {
height: 22,
bgcolor: SCRIM_LABEL,
color: WHITE,
'& .MuiChip-label': { px: 0.75, fontSize: 11 },
};
const overlayIconSx = {
color: WHITE,
bgcolor: SCRIM_BUTTON,
'&:hover': { bgcolor: SCRIM_STRONG },
'&.Mui-disabled': { color: VEIL_MUTED, bgcolor: SCRIM_SOFT },
};
class ArticlePhotosDialog extends Component {
constructor(props) {
super(props);
this.fileInputRef = createRef();
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);
this.handleEditInChat = this.handleEditInChat.bind(this);
}
handleClose() {
if (this.state.busy) return;
this.props.onClose();
}
handleEditInChat(photo, index) {
const { article: articleData } = this.props;
const sku = articleData?.sku ?? '';
const imageNr = index + 1;
const prompt = `Artikelbild Nr ${imageNr} von Artikel SKU ${sku} folgende Änderung: `;
chatAutomat.actions.openWithPrompt(prompt);
}
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 articleApi.uploadImage(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');
const updated = articleWithUploadedPhoto(articleData, res.image);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
async handlePasteClick() {
if (this.state.busy) return;
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) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void this.uploadRawImage(file);
}
async deletePhoto(fullHash) {
const { article: articleData, onArticleUpdated } = this.props;
this.setState({ busy: true });
try {
await articleApi.deleteImage(articleData.sku, fullHash);
const updated = articleWithoutPhoto(articleData, fullHash);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
async movePhoto(fullHash, delta) {
const { article: articleData, onArticleUpdated } = this.props;
const photos = sortedPhotos(articleData);
const index = photos.findIndex((row) => row.fullHash === fullHash);
const target = index + delta;
if (index < 0 || target < 0 || target >= photos.length) return;
const next = [...photos];
const [row] = next.splice(index, 1);
next.splice(target, 0, row);
const fullHashes = next.map((item) => item.fullHash);
this.setState({ busy: true });
try {
const res = await articleApi.orderImages(articleData.sku, fullHashes);
requireObject(res, 'PUT /api/article/:sku/images/order');
requireArray(res.fullHashes, 'fullHashes');
const updated = articleWithPhotoOrder(articleData, res.fullHashes);
artikelAutomat.actions.setArticle(updated);
onArticleUpdated?.(updated);
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
renderPhotoTile(photo, index, total) {
const { busy } = this.state;
const hasDimensions = photo.fullW != null && photo.fullH != null;
const hasSize = photo.fullSize != null;
return (
<Box
key={photo.fullHash}
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
>
<ArticlePhoto photo={photo} size={200} sx={{ position: 'absolute', inset: 0 }} />
<Box
sx={{
position: 'absolute',
inset: 0,
zIndex: 1,
pointerEvents: 'none',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
p: 0.75,
}}
>
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 0.5,
pointerEvents: 'none',
}}
>
{hasDimensions ? (
<Chip
size="small"
label={formatPhotoDimensions(photo.fullW, photo.fullH)}
sx={overlayChipSx}
/>
) : null}
{hasSize ? (
<Chip
size="small"
label={formatPhotoSizeKb(photo.fullSize)}
sx={overlayChipSx}
/>
) : null}
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
pointerEvents: 'auto',
}}
>
<IconButton
type="button"
size="small"
aria-label="Bearbeiten"
disabled={busy}
onClick={() => this.handleEditInChat(photo, index)}
sx={overlayIconSx}
>
<EditOutlinedIcon fontSize="small" />
</IconButton>
<Box
sx={{
display: 'flex',
gap: 0.25,
}}
>
<IconButton
type="button"
size="small"
aria-label="Nach vorne"
disabled={busy || index === 0}
onClick={() => void this.movePhoto(photo.fullHash, -1)}
sx={overlayIconSx}
>
<KeyboardArrowUpIcon fontSize="small" />
</IconButton>
<IconButton
type="button"
size="small"
aria-label="Nach hinten"
disabled={busy || index >= total - 1}
onClick={() => void this.movePhoto(photo.fullHash, 1)}
sx={overlayIconSx}
>
<KeyboardArrowDownIcon fontSize="small" />
</IconButton>
<IconButton
type="button"
size="small"
aria-label="Löschen"
disabled={busy}
onClick={() => void this.deletePhoto(photo.fullHash)}
sx={overlayIconSx}
>
<DeleteOutlineOutlinedIcon fontSize="small" />
</IconButton>
</Box>
</Box>
</Box>
</Box>
);
}
render() {
const { open, article: articleData } = this.props;
const { busy } = this.state;
const photos = sortedPhotos(articleData);
return (
<>
<AppDialog open={open} onClose={this.handleClose} maxWidth="md" fullWidth>
<DialogTitle>Bilder</DialogTitle>
<DialogContent dividers>
{photos.length === 0 ? (
<Box sx={{ color: 'text.secondary', py: 2 }}>Keine Bilder.</Box>
) : (
<Box
sx={{
display: 'flex',
flexWrap: 'wrap',
gap: 1.5,
}}
>
{photos.map((photo, index) =>
this.renderPhotoTile(photo, index, photos.length),
)}
</Box>
)}
</DialogContent>
<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>
</AppDialog>
<input
ref={this.fileInputRef}
type="file"
accept="image/*"
hidden
onChange={this.handleFileChange}
/>
<ArticleImageSearchDialog
open={this.state.searchOpen}
article={articleData}
onClose={this.handleSearchClose}
onArticleUpdated={this.props.onArticleUpdated}
/>
</>
);
}
}
export default ArticlePhotosDialog;