462 lines
13 KiB
JavaScript
462 lines
13 KiB
JavaScript
import { PureComponent } from 'react';
|
|
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_SIZE, Tile, tileBaseSx } from 'dashgui/tiles';
|
|
import { formatPrice } from '../../utils/price';
|
|
import { articleSku } from '../article/articleRoute';
|
|
import {
|
|
getThumbHashDataUrl,
|
|
getCachedImage,
|
|
isImageLoaded,
|
|
markImageLoaded,
|
|
} from '../article/ThumbHashImage';
|
|
import { BrandLabel } from '../brand';
|
|
import { thumb } from '../../api';
|
|
|
|
const MIN_EDGE_STRETCH = 10;
|
|
|
|
function drawContainedWithStretchedEdges(
|
|
ctx,
|
|
source,
|
|
srcW,
|
|
srcH,
|
|
dstW,
|
|
dstH,
|
|
minStretch = MIN_EDGE_STRETCH,
|
|
) {
|
|
ctx.clearRect(0, 0, dstW, dstH);
|
|
ctx.imageSmoothingEnabled = true;
|
|
ctx.imageSmoothingQuality = 'high';
|
|
|
|
const stretch = Math.min(minStretch, Math.max(0, Math.floor(Math.min(dstW, dstH) / 4)));
|
|
const innerW = dstW - stretch * 2;
|
|
const innerH = dstH - stretch * 2;
|
|
const scale = Math.min(innerW / srcW, innerH / srcH);
|
|
const fitW = srcW * scale;
|
|
const fitH = srcH * scale;
|
|
const offsetX = (dstW - fitW) / 2;
|
|
const offsetY = (dstH - fitH) / 2;
|
|
|
|
const leftW = Math.ceil(offsetX);
|
|
const rightX = Math.floor(offsetX + fitW);
|
|
const rightW = dstW - rightX;
|
|
const topH = Math.ceil(offsetY);
|
|
const botY = Math.floor(offsetY + fitH);
|
|
const botH = dstH - botY;
|
|
|
|
// Edge fill: a strongly blurred layer, crossfaded smoothly into a lightly
|
|
// blurred layer near the image (masked by a blurred rect, no visible seams).
|
|
// Rects overdraw past the canvas and under the center so blur never fades
|
|
// into transparency.
|
|
const maxBlur = Math.max(2, stretch * 0.6);
|
|
const drawEdges = (c, blur) => {
|
|
const pad = Math.ceil(blur * 3);
|
|
c.filter = `blur(${blur}px)`;
|
|
|
|
// 1. Four corners (1x1 source border pixels stretched)
|
|
c.drawImage(source, 0, 0, 1, 1, -pad, -pad, leftW + pad * 2, topH + pad * 2);
|
|
c.drawImage(source, srcW - 1, 0, 1, 1, rightX - pad, -pad, rightW + pad * 2, topH + pad * 2);
|
|
c.drawImage(source, 0, srcH - 1, 1, 1, -pad, botY - pad, leftW + pad * 2, botH + pad * 2);
|
|
c.drawImage(
|
|
source, srcW - 1, srcH - 1, 1, 1,
|
|
rightX - pad, botY - pad, rightW + pad * 2, botH + pad * 2,
|
|
);
|
|
|
|
// 2. Four edge bars (stretched 1px border rows/columns)
|
|
c.drawImage(source, 0, 0, srcW, 1, offsetX, -pad, fitW, topH + pad * 2);
|
|
c.drawImage(source, 0, srcH - 1, srcW, 1, offsetX, botY - pad, fitW, botH + pad * 2);
|
|
c.drawImage(source, 0, 0, 1, srcH, -pad, offsetY, leftW + pad * 2, fitH);
|
|
c.drawImage(source, srcW - 1, 0, 1, srcH, rightX - pad, offsetY, rightW + pad * 2, fitH);
|
|
|
|
c.filter = 'none';
|
|
};
|
|
|
|
drawEdges(ctx, maxBlur);
|
|
|
|
const light = document.createElement('canvas');
|
|
light.width = dstW;
|
|
light.height = dstH;
|
|
const lctx = light.getContext('2d');
|
|
if (lctx) {
|
|
drawEdges(lctx, Math.max(0.5, maxBlur * 0.15));
|
|
// Mask: blurred rect around the image -> opaque near it, fading outward.
|
|
const grow = maxBlur * 0.5;
|
|
lctx.globalCompositeOperation = 'destination-in';
|
|
lctx.filter = `blur(${maxBlur}px)`;
|
|
lctx.fillStyle = '#000';
|
|
lctx.fillRect(
|
|
offsetX - grow,
|
|
offsetY - grow,
|
|
fitW + grow * 2,
|
|
fitH + grow * 2,
|
|
);
|
|
lctx.filter = 'none';
|
|
ctx.drawImage(light, 0, 0);
|
|
}
|
|
|
|
// 3. Center image (aspect ratio preserved / contained)
|
|
ctx.drawImage(source, 0, 0, srcW, srcH, offsetX, offsetY, fitW, fitH);
|
|
}
|
|
|
|
class ArticleTileBackdrop extends PureComponent {
|
|
canvasRef = null;
|
|
img = null;
|
|
observer = null;
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
|
|
const fullHash = props.fullHash;
|
|
if (fullHash) {
|
|
const cached = getCachedImage(fullHash);
|
|
if (cached) {
|
|
this.img = cached;
|
|
} else {
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
markImageLoaded(fullHash, img);
|
|
if (this.img === img) {
|
|
this.paint();
|
|
}
|
|
};
|
|
img.src = thumb.url(fullHash);
|
|
this.img = img;
|
|
if (img.complete && img.naturalWidth) {
|
|
markImageLoaded(fullHash, img);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.loadImage();
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (prevProps.thumbHash !== this.props.thumbHash) {
|
|
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
|
|
}
|
|
if (prevProps.fullHash !== this.props.fullHash) {
|
|
this.img = getCachedImage(this.props.fullHash);
|
|
this.loadImage();
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.observer?.disconnect();
|
|
this.img = null;
|
|
}
|
|
|
|
loadImage() {
|
|
const { fullHash } = this.props;
|
|
if (!fullHash) return;
|
|
const cached = getCachedImage(fullHash);
|
|
if (cached) {
|
|
this.img = cached;
|
|
this.paint();
|
|
return;
|
|
}
|
|
if (this.img && this.img.src === thumb.url(fullHash)) {
|
|
if (this.img.complete && this.img.naturalWidth) {
|
|
markImageLoaded(fullHash, this.img);
|
|
this.paint();
|
|
}
|
|
return;
|
|
}
|
|
const img = new Image();
|
|
img.onload = () => {
|
|
markImageLoaded(fullHash, img);
|
|
if (this.img === img) {
|
|
this.paint();
|
|
}
|
|
};
|
|
img.src = thumb.url(fullHash);
|
|
this.img = img;
|
|
if (img.complete && img.naturalWidth) {
|
|
markImageLoaded(fullHash, img);
|
|
this.paint();
|
|
}
|
|
}
|
|
|
|
setCanvas = (canvas) => {
|
|
this.canvasRef = canvas;
|
|
if (canvas && typeof ResizeObserver !== 'undefined') {
|
|
this.observer?.disconnect();
|
|
this.observer = new ResizeObserver(() => this.paint());
|
|
this.observer.observe(canvas);
|
|
}
|
|
this.paint();
|
|
};
|
|
|
|
paint = () => {
|
|
const canvas = this.canvasRef;
|
|
if (!canvas) return;
|
|
const rect = canvas.getBoundingClientRect();
|
|
const dstW = Math.round(rect.width) || canvas.clientWidth || TILE_SIZE;
|
|
const dstH = Math.round(rect.height) || canvas.clientHeight || TILE_SIZE;
|
|
if (!dstW || !dstH) return;
|
|
|
|
if (canvas.width !== dstW) canvas.width = dstW;
|
|
if (canvas.height !== dstH) canvas.height = dstH;
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return;
|
|
|
|
const img = (this.img && this.img.complete && this.img.naturalWidth)
|
|
? this.img
|
|
: getCachedImage(this.props.fullHash);
|
|
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
|
|
this.img = img;
|
|
drawContainedWithStretchedEdges(
|
|
ctx,
|
|
img,
|
|
img.naturalWidth,
|
|
img.naturalHeight,
|
|
dstW,
|
|
dstH,
|
|
);
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const { fullHash } = this.props;
|
|
const showThumb = !isImageLoaded(fullHash) && Boolean(this.thumbUrl);
|
|
return (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{showThumb ? (
|
|
<Box
|
|
component="img"
|
|
src={this.thumbUrl}
|
|
alt=""
|
|
aria-hidden
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
objectFit: 'cover',
|
|
}}
|
|
/>
|
|
) : null}
|
|
<Box
|
|
component="canvas"
|
|
ref={this.setCanvas}
|
|
aria-hidden
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'block',
|
|
}}
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 }} />;
|
|
}
|
|
|
|
renderSecondary() {
|
|
const { article } = this.props;
|
|
if (!article) return null;
|
|
const sku = articleSku(article);
|
|
const brand = article.brand;
|
|
return (
|
|
<>
|
|
{brand?.name ? (
|
|
<BrandLabel
|
|
brand={brand}
|
|
name={brand.name}
|
|
size={16}
|
|
spacing={0.5}
|
|
typographyProps={{
|
|
variant: 'caption',
|
|
color: 'text.secondary',
|
|
fontWeight: 500,
|
|
noWrap: true,
|
|
}}
|
|
/>
|
|
) : null}
|
|
{sku ? (
|
|
<Typography variant="caption" color="text.secondary" noWrap sx={{ maxWidth: '100%' }}>
|
|
{sku}
|
|
</Typography>
|
|
) : null}
|
|
{article.net != null && (
|
|
<Typography variant="caption" fontWeight={600}>
|
|
{formatPrice(article.net)}
|
|
</Typography>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
renderExtra() {
|
|
const { article } = this.props;
|
|
if (!article) return null;
|
|
const isParent = Boolean(article.isParent || article.is_parent);
|
|
const childCount = Number(article.childCount) || 0;
|
|
if (!isParent && childCount <= 0) return null;
|
|
return (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
top: 6,
|
|
right: 6,
|
|
zIndex: 1,
|
|
px: 0.75,
|
|
py: 0.25,
|
|
borderRadius: 999,
|
|
bgcolor: 'rgba(0, 0, 0, 0.65)',
|
|
color: 'common.white',
|
|
fontSize: 11,
|
|
fontWeight: 600,
|
|
lineHeight: 1.2,
|
|
whiteSpace: 'nowrap',
|
|
pointerEvents: 'none',
|
|
}}
|
|
>
|
|
{childCount}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const hasPhoto = this.hasPhoto();
|
|
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 }}>
|
|
{hasPhoto ? (
|
|
<ArticleTileBackdrop
|
|
fullHash={article.fullHash}
|
|
thumbHash={article.thumbHash}
|
|
/>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
position: 'absolute',
|
|
inset: 0,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
pb: 4,
|
|
}}
|
|
>
|
|
{this.renderIcon()}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
{this.renderExtra()}
|
|
<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',
|
|
color: '#fff',
|
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
|
}}
|
|
>
|
|
{article?.brand?.name ? (
|
|
<BrandLabel
|
|
brand={article.brand}
|
|
name={article.brand.name}
|
|
size={14}
|
|
spacing={0.5}
|
|
typographyProps={{
|
|
variant: 'caption',
|
|
noWrap: true,
|
|
sx: {
|
|
color: '#b3e5fc',
|
|
opacity: 1,
|
|
fontWeight: 500,
|
|
lineHeight: 1.1,
|
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
|
},
|
|
}}
|
|
sx={{ mb: 0.25 }}
|
|
/>
|
|
) : null}
|
|
{label ? (
|
|
<Typography
|
|
variant="body2"
|
|
fontWeight={600}
|
|
noWrap
|
|
sx={{
|
|
color: '#111',
|
|
lineHeight: 1.2,
|
|
textShadow: '0 0 3px rgba(255, 255, 255, 1), 0 0 6px rgba(255, 255, 255, 0.9), 0 1px 2px rgba(255, 255, 255, 0.9)',
|
|
}}
|
|
>
|
|
{label}
|
|
</Typography>
|
|
) : null}
|
|
{sku ? (
|
|
<Typography
|
|
variant="caption"
|
|
noWrap
|
|
sx={{
|
|
color: '#ffffff',
|
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
|
}}
|
|
>
|
|
{sku}
|
|
</Typography>
|
|
) : null}
|
|
{article.net != null && (
|
|
<Typography
|
|
variant="caption"
|
|
fontWeight={600}
|
|
component="div"
|
|
sx={{
|
|
color: '#b9f6ca',
|
|
textShadow: '0 1px 3px rgba(0, 0, 0, 0.95), 0 0 2px rgba(0, 0, 0, 0.85)',
|
|
}}
|
|
>
|
|
{formatPrice(article.net)}
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
</ButtonBase>
|
|
);
|
|
}
|
|
}
|