u
This commit is contained in:
@@ -1,374 +1,17 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
import { PureComponent } from 'react';
|
||||
import PicturesDialog from '../PicturesDialog';
|
||||
|
||||
/**
|
||||
* Article photos dialog wrapping the unified PicturesDialog.
|
||||
* Allows multiple photos with reordering, edit in chat, clipboard paste, upload, search, and delete.
|
||||
*
|
||||
* Props: open, article, onClose, onArticleUpdated
|
||||
*/
|
||||
// #region component
|
||||
export default class ArticlePhotosDialog extends PureComponent {
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <PicturesDialog {...this.props} single={false} entityType="article" />;
|
||||
}
|
||||
}
|
||||
// #endregion
|
||||
|
||||
export default ArticlePhotosDialog;
|
||||
|
||||
Reference in New Issue
Block a user