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:
seb
2026-08-01 04:32:49 +02:00
parent 701487f525
commit 09fb1da335
10 changed files with 368 additions and 36 deletions

View File

@@ -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`);
},

View File

@@ -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>

View File

@@ -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));

View File

@@ -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',

View File

@@ -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>
);
}
}

View File

@@ -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' }
: {}),

View File

@@ -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;
}