Enhance article photo handling and API documentation.
- Updated API documentation to reflect changes in article data structure. - Improved ArticlePhoto component to support dynamic sizing and object fit options. - Added performance logging for image upload in ArticlePhotosDialog and ArticlePhotoTile components. - Enhanced ArticleTile to conditionally render photos and adjusted styling for better layout. - Optimized image preparation process with configurable AVIF encoding speed.
This commit is contained in:
@@ -151,7 +151,7 @@ export const article = {
|
||||
/**
|
||||
* GET /api/article/:sku → { article }
|
||||
* Detail: prices[], photos[], eans[], brand, unit, categoryIds, …
|
||||
* (Flat net/taxRate/thumb* only on category tree / getCategoryArticles.)
|
||||
* (Flat net/taxRate/fullHash/thumb* only on category tree / getCategoryArticles.)
|
||||
*/
|
||||
getBySku(sku) {
|
||||
return http(`/api/article/${encodeURIComponent(sku)}`);
|
||||
@@ -243,7 +243,10 @@ export const category = {
|
||||
return http('/api/category/tree');
|
||||
},
|
||||
|
||||
/** GET /api/category/:id/articles → { articles: [...] } */
|
||||
/**
|
||||
* GET /api/category/:id/articles → { articles: [...] }
|
||||
* Article rows: sku, name, net, taxRate, fullHash, thumbW, thumbH, thumbHash, …
|
||||
*/
|
||||
getArticles(categoryId) {
|
||||
return http(`/api/category/${categoryId}/articles`);
|
||||
},
|
||||
|
||||
@@ -26,7 +26,13 @@ class ArticlePhoto extends Component {
|
||||
}
|
||||
|
||||
render() {
|
||||
const { photo, size = 200 } = this.props;
|
||||
const {
|
||||
photo,
|
||||
size = 200,
|
||||
fill = false,
|
||||
objectFit = fill ? 'cover' : 'contain',
|
||||
sx,
|
||||
} = this.props;
|
||||
const { thumbLoaded } = this.state;
|
||||
const fullHash = photo?.fullHash;
|
||||
const hasPreview = photo?.thumbHash != null;
|
||||
@@ -39,28 +45,32 @@ class ArticlePhoto extends Component {
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
width: size,
|
||||
height: size,
|
||||
maxWidth: '100%',
|
||||
...(fill
|
||||
? { width: '100%', height: '100%' }
|
||||
: { width: size, height: size, maxWidth: '100%', flexShrink: 0 }),
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 2,
|
||||
borderRadius: fill ? 0 : 2,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'rgba(0, 0, 0, 0.35)',
|
||||
flexShrink: 0,
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
{hasPreview && !thumbLoaded ? (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
aspectRatio,
|
||||
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
||||
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
||||
}}
|
||||
sx={
|
||||
fill || objectFit === 'cover'
|
||||
? { position: 'absolute', inset: 0 }
|
||||
: {
|
||||
position: 'absolute',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
aspectRatio,
|
||||
width: photo.thumbW >= photo.thumbH ? '100%' : 'auto',
|
||||
height: photo.thumbH > photo.thumbW ? '100%' : 'auto',
|
||||
}
|
||||
}
|
||||
>
|
||||
<ThumbHashImage thumbHash={photo.thumbHash} />
|
||||
</Box>
|
||||
@@ -72,15 +82,25 @@ class ArticlePhoto extends Component {
|
||||
alt=""
|
||||
onLoad={this.handleThumbLoad}
|
||||
onError={this.handleThumbError}
|
||||
sx={{
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}}
|
||||
sx={
|
||||
fill || objectFit === 'cover'
|
||||
? {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
: {
|
||||
display: 'block',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
width: 'auto',
|
||||
height: 'auto',
|
||||
objectFit: 'contain',
|
||||
opacity: thumbLoaded ? 1 : 0,
|
||||
}
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
@@ -28,7 +28,13 @@ class ArticlePhotoTile extends Component {
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const uploadStarted = performance.now();
|
||||
const res = await article.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');
|
||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||
|
||||
@@ -61,7 +61,13 @@ class ArticlePhotosDialog extends Component {
|
||||
this.setState({ busy: true });
|
||||
try {
|
||||
const file = await prepareArticlePhoto(rawBlob);
|
||||
const uploadStarted = performance.now();
|
||||
const res = await article.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');
|
||||
onArticleUpdated?.(articleWithUploadedPhoto(articleData, res.image));
|
||||
@@ -136,11 +142,12 @@ class ArticlePhotosDialog extends Component {
|
||||
key={photo.fullHash}
|
||||
sx={{ position: 'relative', width: 200, height: 200, flexShrink: 0 }}
|
||||
>
|
||||
<ArticlePhoto photo={photo} size={200} />
|
||||
<ArticlePhoto photo={photo} size={200} sx={{ position: 'absolute', inset: 0 }} />
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 1,
|
||||
pointerEvents: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import Box from '@mui/material/Box';
|
||||
import ButtonBase from '@mui/material/ButtonBase';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Tile } from './Tile';
|
||||
import { Tile, tileBaseSx } from './Tile';
|
||||
import { formatPrice } from '../../utils/price';
|
||||
import { articleSku } from '../../pages/artikel/articleRoute';
|
||||
import ArticlePhoto from '../article/ArticlePhoto';
|
||||
|
||||
export class ArticleTile extends Tile {
|
||||
hasPhoto() {
|
||||
const { article } = this.props;
|
||||
return article?.thumbHash != null || article?.fullHash != null;
|
||||
}
|
||||
|
||||
renderIcon() {
|
||||
return <Inventory2OutlinedIcon color="action" sx={{ fontSize: 36 }} />;
|
||||
}
|
||||
@@ -28,4 +36,70 @@ export class ArticleTile extends Tile {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.hasPhoto()) {
|
||||
return super.render();
|
||||
}
|
||||
|
||||
const { article, label, sx, disabled } = this.props;
|
||||
const sku = articleSku(article);
|
||||
|
||||
return (
|
||||
<ButtonBase
|
||||
component="button"
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={this.handleClick}
|
||||
sx={{
|
||||
...tileBaseSx,
|
||||
p: 0,
|
||||
gap: 0,
|
||||
position: 'relative',
|
||||
...(disabled ? { opacity: 0.5, pointerEvents: 'none' } : {}),
|
||||
...sx,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'absolute', inset: 0 }}>
|
||||
<ArticlePhoto
|
||||
fill
|
||||
photo={{
|
||||
fullHash: article.fullHash,
|
||||
thumbW: article.thumbW,
|
||||
thumbH: article.thumbH,
|
||||
thumbHash: article.thumbHash,
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
background: 'linear-gradient(transparent, rgba(0,0,0,0.65))',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{label ? (
|
||||
<Typography
|
||||
variant="body2"
|
||||
fontWeight={600}
|
||||
noWrap
|
||||
sx={{ color: '#fff', lineHeight: 1.2 }}
|
||||
>
|
||||
{label}
|
||||
</Typography>
|
||||
) : null}
|
||||
{sku ? (
|
||||
<Typography variant="caption" noWrap sx={{ color: 'rgba(255,255,255,0.85)' }}>
|
||||
{sku}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
</ButtonBase>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@ import Typography from '@mui/material/Typography';
|
||||
|
||||
export const TILE_SIZE = 140;
|
||||
|
||||
/** Matches panel tile rounding (theme.shape.borderRadius × 4 → typically 16px). */
|
||||
export const TILE_BORDER_RADIUS = 4;
|
||||
|
||||
export const tileBaseSx = {
|
||||
aspectRatio: '1 / 1',
|
||||
width: '100%',
|
||||
@@ -16,10 +19,15 @@ export const tileBaseSx = {
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
textAlign: 'center',
|
||||
transition: 'border-color 0.15s, background-color 0.15s',
|
||||
border: 'none',
|
||||
borderRadius: TILE_BORDER_RADIUS,
|
||||
overflow: 'hidden',
|
||||
bgcolor: 'background.paper',
|
||||
boxShadow: 3,
|
||||
transition: 'box-shadow 0.15s, background-color 0.15s',
|
||||
'&:hover': {
|
||||
bgcolor: 'action.hover',
|
||||
borderColor: 'primary.main',
|
||||
boxShadow: 6,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -62,11 +70,6 @@ export class Tile extends Component {
|
||||
onClick={this.handleClick}
|
||||
sx={{
|
||||
...tileBaseSx,
|
||||
border: '1px solid',
|
||||
borderColor: 'divider',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
...(disabled
|
||||
? { opacity: 0.5, pointerEvents: 'none' }
|
||||
: {}),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const MAX_EDGE = 1000;
|
||||
const AVIF_MIME = 'image/avif';
|
||||
const AVIF_QUALITY = 80;
|
||||
/** libaom speed 0–10; higher = faster encode, slightly larger files. Bench: s10 ~8× vs s6, +5% bytes. */
|
||||
const AVIF_SPEED = 9;
|
||||
|
||||
let encodeAvif;
|
||||
let resizeImage;
|
||||
@@ -61,21 +63,48 @@ export async function prepareArticlePhoto(input) {
|
||||
throw new Error('Kein Bild.');
|
||||
}
|
||||
|
||||
const started = performance.now();
|
||||
|
||||
const decodeStarted = performance.now();
|
||||
const originalImageData = await decodeToImageData(input);
|
||||
const decodeMs = performance.now() - decodeStarted;
|
||||
|
||||
const { width, height } = scaledDimensions(
|
||||
originalImageData.width,
|
||||
originalImageData.height,
|
||||
);
|
||||
|
||||
const resizeStarted = performance.now();
|
||||
const resize = await getResizer();
|
||||
const imageData = await resize(originalImageData, { width, height });
|
||||
const resizeMs = performance.now() - resizeStarted;
|
||||
|
||||
const encodeStarted = performance.now();
|
||||
const encode = await getAvifEncoder();
|
||||
const avifBuffer = await encode(imageData, { quality: AVIF_QUALITY });
|
||||
const avifBuffer = await encode(imageData, {
|
||||
quality: AVIF_QUALITY,
|
||||
speed: AVIF_SPEED,
|
||||
});
|
||||
const encodeMs = performance.now() - encodeStarted;
|
||||
|
||||
const baseName = input instanceof File && input.name
|
||||
? input.name.replace(/\.[^.]+$/, '')
|
||||
: 'photo';
|
||||
|
||||
return new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
|
||||
const file = new File([avifBuffer], `${baseName}.avif`, { type: AVIF_MIME });
|
||||
const totalMs = performance.now() - started;
|
||||
|
||||
console.log('[article-photo] process', {
|
||||
totalMs: Math.round(totalMs),
|
||||
decodeMs: Math.round(decodeMs),
|
||||
resizeMs: Math.round(resizeMs),
|
||||
encodeMs: Math.round(encodeMs),
|
||||
quality: AVIF_QUALITY,
|
||||
speed: AVIF_SPEED,
|
||||
inputBytes: input.size,
|
||||
outputBytes: file.size,
|
||||
size: `${width}×${height}`,
|
||||
});
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user