This commit is contained in:
seb
2026-08-01 03:37:40 +02:00
parent 4ea593a0c8
commit 41bc76303a
10 changed files with 904 additions and 68 deletions

View File

@@ -177,6 +177,15 @@ export const article = {
});
},
/**
* DELETE /api/article/:sku → 204
*/
delete(sku) {
return http(`/api/article/${encodeURIComponent(sku)}`, {
method: 'DELETE',
});
},
/**
* POST /api/article/:sku/image
* Body: raw AVIF bytes (Content-Type: image/avif, max 1000×1000, prepared client-side via WASM)
@@ -189,6 +198,28 @@ export const article = {
body: file,
});
},
/**
* DELETE /api/article/:sku/image/:fullHash → 204
*/
deleteImage(sku, fullHash) {
return http(
`/api/article/${encodeURIComponent(sku)}/image/${encodeURIComponent(fullHash)}`,
{ method: 'DELETE' },
);
},
/**
* PUT /api/article/:sku/images/order
* Body: { fullHashes: string[] } (every image exactly once)
* → 200 { fullHashes: string[] }
*/
orderImages(sku, fullHashes) {
return http(`/api/article/${encodeURIComponent(sku)}/images/order`, {
method: 'PUT',
body: { fullHashes },
});
},
};
/**

View File

@@ -26,7 +26,7 @@ class ArticlePhoto extends Component {
}
render() {
const { photo } = this.props;
const { photo, size = 200 } = this.props;
const { thumbLoaded } = this.state;
const fullHash = photo?.fullHash;
const hasPreview = photo?.thumbHash != null;
@@ -39,8 +39,8 @@ class ArticlePhoto extends Component {
<Box
sx={{
position: 'relative',
width: 200,
height: 200,
width: size,
height: size,
maxWidth: '100%',
display: 'flex',
alignItems: 'center',
@@ -48,6 +48,7 @@ class ArticlePhoto extends Component {
borderRadius: 2,
overflow: 'hidden',
bgcolor: 'rgba(0, 0, 0, 0.35)',
flexShrink: 0,
}}
>
{hasPreview && !thumbLoaded ? (

View File

@@ -1,5 +1,6 @@
import { useRef, useState } from 'react';
import { Component, createRef } from 'react';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
import { article } from '../../api';
import { PanelTile } from '../panels';
import { requireObject } from '../../utils/apiShape';
@@ -8,22 +9,23 @@ import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { articleWithUploadedPhoto, primaryPhoto } from '../../utils/articleDetail';
import ArticlePhoto from './ArticlePhoto';
import ArticlePhotosDialog from './ArticlePhotosDialog';
export default function ArticlePhotoTile({
article: articleData,
onArticleUpdated,
wide = 1,
minHeight = 200,
sx,
tileKey,
}) {
const fileInputRef = useRef(null);
const [busy, setBusy] = useState(false);
const photo = primaryPhoto(articleData);
const hasPhoto = photo?.thumbHash != null;
class ArticlePhotoTile extends Component {
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.state = { busy: false, editOpen: false };
this.handlePasteClick = this.handlePasteClick.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
this.handleEditOpen = this.handleEditOpen.bind(this);
this.handleEditClose = this.handleEditClose.bind(this);
this.uploadRawImage = this.uploadRawImage.bind(this);
}
const uploadRawImage = async (rawBlob) => {
setBusy(true);
async uploadRawImage(rawBlob) {
const { article: articleData, onArticleUpdated } = this.props;
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadImage(articleData.sku, file);
@@ -33,53 +35,88 @@ export default function ArticlePhotoTile({
} catch (err) {
notifyError(err);
} finally {
setBusy(false);
this.setState({ busy: false });
}
};
}
const handlePasteClick = () => {
if (busy) return;
handlePasteClick() {
if (this.state.busy) return;
void pasteImageOrFallback(() => {
fileInputRef.current?.click();
this.fileInputRef.current?.click();
}).then((image) => {
if (image) void uploadRawImage(image);
if (image) void this.uploadRawImage(image);
});
};
}
const handleFileChange = (event) => {
handleFileChange(event) {
const file = event.target.files?.[0];
event.target.value = '';
if (!file) return;
void uploadRawImage(file);
};
void this.uploadRawImage(file);
}
return (
<>
<PanelTile
wide={wide}
minHeight={minHeight}
sx={sx}
tileKey={tileKey}
action={
hasPhoto
? undefined
: {
label: 'Bild einfügen',
icon: ContentPasteIcon,
onClick: handlePasteClick,
busy,
}
}
>
<ArticlePhoto photo={photo} />
</PanelTile>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleFileChange}
/>
</>
);
handleEditOpen() {
this.setState({ editOpen: true });
}
handleEditClose() {
this.setState({ editOpen: false });
}
render() {
const {
article: articleData,
onArticleUpdated,
wide = 1,
minHeight = 200,
sx,
tileKey,
} = this.props;
const { busy, editOpen } = this.state;
const photo = primaryPhoto(articleData);
const hasPhoto = photo?.thumbHash != null || photo?.fullHash != null;
return (
<>
<PanelTile
wide={wide}
minHeight={minHeight}
sx={sx}
tileKey={tileKey}
action={
hasPhoto
? {
label: 'Bilder bearbeiten',
icon: EditOutlinedIcon,
onClick: this.handleEditOpen,
busy: false,
}
: {
label: 'Bild einfügen',
icon: ContentPasteIcon,
onClick: this.handlePasteClick,
busy,
}
}
>
<ArticlePhoto photo={photo} />
</PanelTile>
<input
ref={this.fileInputRef}
type="file"
accept="image/*"
hidden
onChange={this.handleFileChange}
/>
<ArticlePhotosDialog
open={editOpen}
article={articleData}
onClose={this.handleEditClose}
onArticleUpdated={onArticleUpdated}
/>
</>
);
}
}
export default ArticlePhotoTile;

View File

@@ -0,0 +1,270 @@
import { Component, createRef } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Chip from '@mui/material/Chip';
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 ContentPasteIcon from '@mui/icons-material/ContentPaste';
import DeleteOutlineOutlinedIcon from '@mui/icons-material/DeleteOutlineOutlined';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
import { article } from '../../api';
import { requireArray, requireObject } from '../../utils/apiShape';
import {
articleWithPhotoOrder,
articleWithUploadedPhoto,
articleWithoutPhoto,
formatPhotoDimensions,
formatPhotoSizeKb,
sortedPhotos,
} from '../../utils/articleDetail';
import { notifyError } from '../../utils/notify';
import { pasteImageOrFallback } from '../../utils/pasteImage';
import { prepareArticlePhoto } from '../../utils/prepareArticlePhoto';
import ArticlePhoto from './ArticlePhoto';
const overlayChipSx = {
height: 22,
bgcolor: 'rgba(0, 0, 0, 0.65)',
color: '#fff',
'& .MuiChip-label': { px: 0.75, fontSize: 11 },
};
const overlayIconSx = {
color: '#fff',
bgcolor: 'rgba(0, 0, 0, 0.55)',
'&:hover': { bgcolor: 'rgba(0, 0, 0, 0.75)' },
'&.Mui-disabled': { color: 'rgba(255,255,255,0.35)', bgcolor: 'rgba(0,0,0,0.35)' },
};
class ArticlePhotosDialog extends Component {
constructor(props) {
super(props);
this.fileInputRef = createRef();
this.state = { busy: false };
this.handleClose = this.handleClose.bind(this);
this.handlePasteClick = this.handlePasteClick.bind(this);
this.handleFileChange = this.handleFileChange.bind(this);
this.uploadRawImage = this.uploadRawImage.bind(this);
}
handleClose() {
if (this.state.busy) return;
this.props.onClose();
}
async uploadRawImage(rawBlob) {
const { article: articleData, onArticleUpdated } = this.props;
this.setState({ busy: true });
try {
const file = await prepareArticlePhoto(rawBlob);
const res = await article.uploadImage(articleData.sku, file);
requireObject(res, 'POST /api/article/:sku/image');
requireObject(res.image, 'image');
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
} catch (err) {
notifyError(err);
} finally {
this.setState({ busy: false });
}
}
handlePasteClick() {
if (this.state.busy) return;
void pasteImageOrFallback(() => {
this.fileInputRef.current?.click();
}).then((image) => {
if (image) void this.uploadRawImage(image);
});
}
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 article.deleteImage(articleData.sku, fullHash);
onArticleUpdated?.(articleWithoutPhoto(articleData, fullHash));
} 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 article.orderImages(articleData.sku, fullHashes);
requireObject(res, 'PUT /api/article/:sku/images/order');
requireArray(res.fullHashes, 'fullHashes');
onArticleUpdated?.(articleWithPhotoOrder(articleData, res.fullHashes));
} 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} />
<Box
sx={{
position: 'absolute',
inset: 0,
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',
justifyContent: 'flex-end',
gap: 0.25,
pointerEvents: 'auto',
}}
>
<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>
);
}
render() {
const { open, article: articleData } = this.props;
const { busy } = this.state;
const photos = sortedPhotos(articleData);
return (
<>
<Dialog 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' }}>
<Button
type="button"
startIcon={<ContentPasteIcon />}
onClick={this.handlePasteClick}
disabled={busy}
>
Bild einfügen
</Button>
<Button type="button" onClick={this.handleClose} disabled={busy}>
Schließen
</Button>
</DialogActions>
</Dialog>
<input
ref={this.fileInputRef}
type="file"
accept="image/*"
hidden
onChange={this.handleFileChange}
/>
</>
);
}
}
export default ArticlePhotosDialog;

View File

@@ -0,0 +1,294 @@
import { Component } from 'react';
import Box from '@mui/material/Box';
import Button from '@mui/material/Button';
import Tab from '@mui/material/Tab';
import Tabs from '@mui/material/Tabs';
import Typography from '@mui/material/Typography';
import AccountTreeIcon from '@mui/icons-material/AccountTree';
import CategoryIcon from '@mui/icons-material/Category';
import DeleteIcon from '@mui/icons-material/Delete';
import HistoryIcon from '@mui/icons-material/History';
import LabelIcon from '@mui/icons-material/Label';
import LocalShippingIcon from '@mui/icons-material/LocalShipping';
import ManageSearchIcon from '@mui/icons-material/ManageSearch';
import SettingsIcon from '@mui/icons-material/Settings';
import { article } from '../../api';
import { notifyError } from '../../utils/notify';
import { PanelTile } from '../panels';
import { PANEL_TILE_BORDER_RADIUS, responsiveColumnSpan } from '../panels/panelTileLayout';
const TAB = {
history: 'history',
supplier: 'supplier',
seo: 'seo',
label: 'label',
attributes: 'attributes',
compound: 'compound',
settings: 'settings',
};
const ALWAYS_TABS = [
{ id: TAB.history, label: 'History', color: '#fed7aa', Icon: HistoryIcon },
{ id: TAB.supplier, label: 'Supplier', color: '#bbf7d0', Icon: LocalShippingIcon },
{ id: TAB.seo, label: 'SEO & Beschreibung', color: '#99f6e4', Icon: ManageSearchIcon },
{ id: TAB.label, label: 'Label', color: '#fde68a', Icon: LabelIcon },
{ id: TAB.attributes, label: 'Attribute und Merkmale', color: '#bfdbfe', Icon: CategoryIcon },
];
const COMPOUND_TAB = {
id: TAB.compound,
label: 'Compound Editor',
color: '#e9d5ff',
Icon: AccountTreeIcon,
};
const SETTINGS_TAB = {
id: TAB.settings,
label: 'Einstellungen',
color: '#fca5a5',
Icon: SettingsIcon,
};
function isCompoundArticle(article) {
return (
article?.articleListCount > 0 ||
article?.optionsCount > 0 ||
article?.childCount > 0 ||
article?.fatherArticleId != null
);
}
function visibleTabs(article) {
const tabs = [...ALWAYS_TABS];
if (isCompoundArticle(article)) {
tabs.push(COMPOUND_TAB);
}
tabs.push(SETTINGS_TAB);
return tabs;
}
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
return (
<Box sx={{ mb: 1 }}>
{label ? (
<Typography variant="caption" color="text.secondary" display="block">
{label}
</Typography>
) : null}
<Typography variant="body2" component="div">
{children}
</Typography>
</Box>
);
}
function Placeholder({ label }) {
return (
<Typography variant="body2" color="text.secondary">
{label} — noch nicht verfügbar.
</Typography>
);
}
class ArticleTileTab extends Component {
constructor(props) {
super(props);
this.state = { tab: TAB.seo, busy: false };
this.handleTabChange = this.handleTabChange.bind(this);
this.handleDeleteArticle = this.handleDeleteArticle.bind(this);
}
componentDidUpdate(prevProps) {
if (prevProps.article === this.props.article) return;
const tabs = visibleTabs(this.props.article);
if (!tabs.some((row) => row.id === this.state.tab)) {
this.setState({ tab: TAB.seo });
}
}
handleTabChange(_event, tab) {
this.setState({ tab });
}
async handleDeleteArticle() {
const { article: articleData, onArticleDeleted } = this.props;
if (this.state.busy) return;
if (!window.confirm(`Artikel „${articleData.name ?? articleData.sku}“ wirklich löschen?`)) {
return;
}
this.setState({ busy: true });
try {
await article.delete(articleData.sku);
onArticleDeleted?.(articleData);
} catch (err) {
notifyError(err);
this.setState({ busy: false });
}
}
renderSeo() {
const { article: articleData } = this.props;
return (
<>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
</>
);
}
renderSettings() {
const { busy } = this.state;
return (
<Box>
<Button
type="button"
color="error"
variant="contained"
startIcon={<DeleteIcon />}
disabled={busy}
onClick={() => void this.handleDeleteArticle()}
>
Artikel löschen
</Button>
</Box>
);
}
renderPanel() {
switch (this.state.tab) {
case TAB.history:
return <Placeholder label="History" />;
case TAB.supplier:
return <Placeholder label="Supplier" />;
case TAB.seo:
return this.renderSeo();
case TAB.label:
return <Placeholder label="Label" />;
case TAB.attributes:
return <Placeholder label="Attribute und Merkmale" />;
case TAB.compound:
return <Placeholder label="Compound Editor" />;
case TAB.settings:
return this.renderSettings();
default:
return null;
}
}
render() {
const { article: articleData, wide = 3, minHeight = 200, sx, tileKey } = this.props;
const tabs = visibleTabs(articleData);
const { tab } = this.state;
const active = tabs.find((row) => row.id === tab) ?? ALWAYS_TABS.find((row) => row.id === TAB.seo);
const activeColor = active?.color;
return (
<Box
sx={{
gridColumn: responsiveColumnSpan(wide),
display: 'flex',
flexDirection: 'column',
minWidth: 0,
filter:
'drop-shadow(0px 2px 4px rgba(0,0,0,0.2)) drop-shadow(0px 4px 5px rgba(0,0,0,0.14)) drop-shadow(0px 1px 10px rgba(0,0,0,0.12))',
}}
>
<Tabs
value={tab}
onChange={this.handleTabChange}
variant="standard"
TabIndicatorProps={{ sx: { display: 'none', height: 0 } }}
sx={{
position: 'relative',
zIndex: 1,
minHeight: 36,
px: 0,
mb: 0,
borderBottom: 'none',
overflow: 'visible',
'& .MuiTabs-scroller': {
px: 0,
borderBottom: 'none',
overflow: 'visible !important',
},
'& .MuiTabs-list, & .MuiTabs-flexContainer': {
flexWrap: 'wrap-reverse',
columnGap: 0.75,
rowGap: 0,
alignItems: 'stretch',
alignContent: 'flex-end',
},
'& .MuiTabs-indicator': { display: 'none' },
'& .MuiTab-root': {
flexShrink: 0,
maxWidth: 'none',
minHeight: 40,
marginBottom: 0,
},
}}
>
{tabs.map((row) => {
const selected = tab === row.id;
const { Icon } = row;
return (
<Tab
key={row.id}
value={row.id}
aria-label={row.label}
title={row.label}
icon={<Icon fontSize="small" />}
sx={{
minHeight: 40,
minWidth: 48,
py: 0.5,
px: 1.25,
border: 'none',
borderBottom: 'none',
borderRadius: '8px 8px 0 0',
textTransform: 'none',
bgcolor: row.color,
opacity: selected ? 1 : 0.72,
color: 'text.primary',
zIndex: selected ? 2 : 1,
boxShadow: 'none',
'&.Mui-selected': {
color: 'text.primary',
borderBottom: 'none',
},
}}
/>
);
})}
</Tabs>
<PanelTile
wide={wide}
minHeight={minHeight}
tileKey={tileKey}
elevation={0}
sx={{
gridColumn: 'auto',
width: '100%',
bgcolor: activeColor,
borderTopLeftRadius: 0,
borderTopRightRadius: (theme) =>
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
borderBottomLeftRadius: (theme) =>
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
borderBottomRightRadius: (theme) =>
`${(theme.shape.borderRadius || 4) * PANEL_TILE_BORDER_RADIUS}px`,
boxShadow: 'none',
...sx,
}}
>
{this.renderPanel()}
</PanelTile>
</Box>
);
}
}
export default ArticleTileTab;

View File

@@ -1,12 +1,14 @@
import { Link as RouterLink } from 'react-router-dom';
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { useSelector } from 'react-redux';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import Typography from '@mui/material/Typography';
import SingleLineTextEdit from '../SingleLineTextEdit';
import ArticlePhotoTile from './ArticlePhotoTile';
import ArticleTileTab from './ArticleTileTab';
import { article } from '../../api';
import { EditablePanelTile, PanelTile, PanelTileGrid } from '../panels';
import { requireObject } from '../../utils/apiShape';
import { requireDefined, requireObject } from '../../utils/apiShape';
import {
articleEans,
brandName,
@@ -16,6 +18,11 @@ import {
import { formatPrice } from '../../utils/price';
import { PANEL_TILE_KEYS } from '../../theme';
import { formatStock } from '../../pages/artikel/searchFormat';
import {
buildCategoryPath,
defaultCategoryPath,
} from '../../pages/artikel/categoryRoute';
import { selectCategoryRootId } from '../../store/categorySlice';
function PanelValue({ label, children }) {
if (children == null || children === '') return null;
@@ -99,6 +106,9 @@ const articleNameFieldHiddenSx = {
};
export default function ArticleView({ article: articleData, onArticleUpdated }) {
const navigate = useNavigate();
const categories = useSelector((state) => state.category.categories);
const rootCategoryId = useSelector(selectCategoryRootId);
const basePrice = primaryPrice(articleData);
const brutto = priceBrutto(basePrice);
const eans = articleEans(articleData);
@@ -111,6 +121,18 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
onArticleUpdated?.(res.article);
};
const handleArticleDeleted = () => {
const categoryId = requireDefined(
articleData.categoryIds?.[0],
'article.categoryIds[0]',
);
if (categories && rootCategoryId != null) {
navigate(buildCategoryPath(categories, rootCategoryId, categoryId));
return;
}
navigate(defaultCategoryPath());
};
return (
<PanelTileGrid>
<EditablePanelTile
@@ -181,12 +203,13 @@ export default function ArticleView({ article: articleData, onArticleUpdated })
</PanelValue>
</PanelTile>
<PanelTile wide={3} tileKey={PANEL_TILE_KEYS.content} title="SEO & Beschreibung" minHeight={200}>
<PanelValue label="Slug">{articleData.slug}</PanelValue>
<PanelValue label="SEO-Titel">{articleData.seoTitle}</PanelValue>
<PanelValue label="SEO-Beschreibung">{articleData.seoDescription}</PanelValue>
<PanelValue label="Beschreibung">{articleData.description}</PanelValue>
</PanelTile>
<ArticleTileTab
wide={3}
tileKey={PANEL_TILE_KEYS.content}
minHeight={200}
article={articleData}
onArticleDeleted={handleArticleDeleted}
/>
<PanelTile wide={1} tileKey={PANEL_TILE_KEYS.lists} title="Listen" minHeight={200}>
<RelationLine

View File

@@ -1,3 +1,5 @@
export { default as ArticleView } from './ArticleView';
export { default as ArticleNameDialog } from './ArticleNameDialog';
export { default as ArticlePhotosDialog } from './ArticlePhotosDialog';
export { default as ArticleTileTab } from './ArticleTileTab';
export { default as NewArticleTile } from './NewArticleTile';

View File

@@ -68,12 +68,21 @@ class PanelTileBase extends Component {
}
render() {
const { theme, wide = 1, children, sx, minHeight = 120, tileKey, action } = this.props;
const {
theme,
wide = 1,
children,
sx,
minHeight = 120,
tileKey,
action,
elevation = 4,
} = this.props;
const actionPadding = action ? TILE_ACTION_PADDING : 0;
return (
<Paper
elevation={4}
elevation={elevation}
sx={panelTilePaperSx({
wide,
sx,

View File

@@ -15,6 +15,9 @@ export function photoFromUploadedImage(image) {
return {
sort: image.sort,
fullHash: image.fullHash,
fullW: image.full.w,
fullH: image.full.h,
fullSize: image.full.size,
thumbW: image.thumb.w,
thumbH: image.thumb.h,
thumbHash: image.thumbHash,
@@ -23,12 +26,36 @@ export function photoFromUploadedImage(image) {
export function articleWithUploadedPhoto(article, image) {
const photo = photoFromUploadedImage(image);
const photos = sortedPhotos(article).filter((row) => row.sort !== photo.sort);
const photos = sortedPhotos(article).filter((row) => row.fullHash !== photo.fullHash);
photos.push(photo);
photos.sort((a, b) => a.sort - b.sort);
return { ...article, photos };
}
/** After DELETE /api/article/:sku/image/:fullHash */
export function articleWithoutPhoto(article, fullHash) {
const photos = sortedPhotos(article)
.filter((row) => row.fullHash !== fullHash)
.map((row, index) => ({ ...row, sort: index }));
return { ...article, photos };
}
/**
* After PUT /api/article/:sku/images/order → { fullHashes }.
* Reorders local photos[] to match; sort = index.
*/
export function articleWithPhotoOrder(article, fullHashes) {
const byHash = new Map(sortedPhotos(article).map((row) => [row.fullHash, row]));
const photos = fullHashes.map((fullHash, index) => {
const row = byHash.get(fullHash);
if (!row) {
throw new Error(`photos order: unbekanntes fullHash ${fullHash}`);
}
return { ...row, sort: index };
});
return { ...article, photos };
}
export function primaryPrice(article) {
if (!Array.isArray(article?.prices) || article.prices.length === 0) return null;
return article.prices.find((row) => row.minmultiplier === 1) ?? article.prices[0];
@@ -47,3 +74,11 @@ export function articleEans(article) {
if (!Array.isArray(article?.eans)) return [];
return article.eans.filter(Boolean);
}
export function formatPhotoSizeKb(fullSize) {
return `${Math.round(fullSize / 1024)} KB`;
}
export function formatPhotoDimensions(fullW, fullH) {
return `${fullW}×${fullH}`;
}