diff --git a/src/components/tiles/ArticleTile.jsx b/src/components/tiles/ArticleTile.jsx
index 7044c6b..65b383a 100644
--- a/src/components/tiles/ArticleTile.jsx
+++ b/src/components/tiles/ArticleTile.jsx
@@ -1,3 +1,4 @@
+import { PureComponent } from 'react';
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
import Box from '@mui/material/Box';
import ButtonBase from '@mui/material/ButtonBase';
@@ -5,8 +6,205 @@ import Typography from '@mui/material/Typography';
import { Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price';
import { articleSku } from '../article/articleRoute';
-import ArticlePhoto from '../article/ArticlePhoto';
+import { decodeThumbHash } 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;
+
+ componentDidMount() {
+ this.loadImage();
+ }
+
+ componentDidUpdate(prevProps) {
+ if (prevProps.fullHash !== this.props.fullHash) {
+ this.loadImage();
+ }
+ }
+
+ componentWillUnmount() {
+ this.observer?.disconnect();
+ this.img = null;
+ }
+
+ loadImage() {
+ const { fullHash } = this.props;
+ if (fullHash) {
+ const img = new Image();
+ img.onload = () => {
+ if (this.img === img) {
+ this.paint();
+ }
+ };
+ img.src = thumb.url(fullHash);
+ this.img = img;
+ if (img.complete && img.naturalWidth) {
+ this.paint();
+ return;
+ }
+ }
+ 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);
+ const dstH = Math.round(rect.height);
+ if (!dstW || !dstH) return;
+
+ canvas.width = dstW;
+ canvas.height = dstH;
+ const ctx = canvas.getContext('2d');
+ if (!ctx) return;
+
+ const img = this.img;
+ if (img && img.complete && img.naturalWidth && img.naturalHeight) {
+ drawContainedWithStretchedEdges(
+ ctx,
+ img,
+ img.naturalWidth,
+ img.naturalHeight,
+ dstW,
+ dstH,
+ );
+ return;
+ }
+
+ const { thumbHash } = this.props;
+ if (thumbHash) {
+ const decoded = decodeThumbHash(thumbHash);
+ if (decoded) {
+ const { w, h, rgba } = decoded;
+ const temp = document.createElement('canvas');
+ temp.width = w;
+ temp.height = h;
+ temp.getContext('2d').putImageData(
+ new ImageData(new Uint8ClampedArray(rgba), w, h),
+ 0,
+ 0,
+ );
+ drawContainedWithStretchedEdges(ctx, temp, w, h, dstW, dstH);
+ }
+ }
+ };
+
+ render() {
+ return (
+
+ );
+ }
+}
export class ArticleTile extends Tile {
hasPhoto() {
@@ -81,10 +279,7 @@ export class ArticleTile extends Tile {
}
render() {
- if (!this.hasPhoto()) {
- return super.render();
- }
-
+ const hasPhoto = this.hasPhoto();
const { article, label, sx, disabled } = this.props;
const sku = articleSku(article);
@@ -104,15 +299,25 @@ export class ArticleTile extends Tile {
}}
>
-
+ {hasPhoto ? (
+
+ ) : (
+
+ {this.renderIcon()}
+
+ )}
{this.renderExtra()}
{article?.brand?.name ? (
@@ -136,7 +343,13 @@ export class ArticleTile extends Tile {
typographyProps={{
variant: 'caption',
noWrap: true,
- sx: { color: 'rgba(255,255,255,0.9)', fontWeight: 500, lineHeight: 1.1 },
+ 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 }}
/>
@@ -146,16 +359,40 @@ export class ArticleTile extends Tile {
variant="body2"
fontWeight={600}
noWrap
- sx={{ color: '#fff', lineHeight: 1.2 }}
+ 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}
) : null}
{sku ? (
-
+
{sku}
) : null}
+ {article.net != null && (
+
+ {formatPrice(article.net)}
+
+ )}
);
diff --git a/src/devtools/AutomatInspector.jsx b/src/devtools/AutomatInspector.jsx
index eefe56e..c89f07b 100644
--- a/src/devtools/AutomatInspector.jsx
+++ b/src/devtools/AutomatInspector.jsx
@@ -1,7 +1,6 @@
import { PureComponent, createRef } from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
-import ButtonBase from '@mui/material/ButtonBase';
import Chip from '@mui/material/Chip';
import Fab from '@mui/material/Fab';
import IconButton from '@mui/material/IconButton';
@@ -10,16 +9,27 @@ import TextField from '@mui/material/TextField';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import BugReportIcon from '@mui/icons-material/BugReport';
-import ChevronRightIcon from '@mui/icons-material/ChevronRight';
-import ClearIcon from '@mui/icons-material/Clear';
import CloseIcon from '@mui/icons-material/Close';
-import ContentCopyIcon from '@mui/icons-material/ContentCopy';
-import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import RefreshIcon from '@mui/icons-material/Refresh';
import UnfoldLessIcon from '@mui/icons-material/UnfoldLess';
import UnfoldMoreIcon from '@mui/icons-material/UnfoldMore';
import { enqueueSnackbar } from 'notistack';
import { notifyError } from '../utils/notify';
+import AutomatEntry from './inspector/AutomatEntry';
+import ActionLogPanel from './inspector/ActionLogPanel';
+import {
+ actionLog,
+ callListeners,
+ formatTime,
+ getRegistry,
+ installActionLogging,
+ logListeners,
+ notifyLog,
+ watchRegistry,
+} from './inspector/actionLog';
+import { formatState } from './inspector/serialize';
+
+export { formatState };
/**
* Dev-only overlay that lists every Automat registered in the
@@ -32,295 +42,6 @@ import { notifyError } from '../utils/notify';
/** Registry polling interval in ms; lazy automats (`delivery-`) appear over time. */
const REGISTRY_POLL_MS = 2000;
-/** Serialization limits so huge states stay renderable. */
-const MAX_DEPTH = 6;
-const MAX_STRING = 1500;
-const MAX_ITEMS = 50;
-
-/**
- * Live registry view. Named automats register themselves in the constructor,
- * so the map may be absent until the first one is created.
- *
- * @returns {Map} Registered automats.
- */
-function getRegistry() {
- const registry = window.__AUTOMATS__;
- return registry instanceof Map ? registry : new Map();
-}
-
-/**
- * Quotes a string and truncates oversized values.
- *
- * @param {string} text Value to quote.
- * @returns {string} Quoted, possibly truncated string.
- */
-function quote(text) {
- if (text.length <= MAX_STRING) return JSON.stringify(text);
- const rest = text.length - MAX_STRING;
- return `${JSON.stringify(text.slice(0, MAX_STRING))} … (+${rest} Zeichen)`;
-}
-
-/**
- * Renders `entries` as an indented block.
- *
- * @param {string} open Opening bracket.
- * @param {string} close Closing bracket.
- * @param {Array} entries Already serialized entries.
- * @param {string} pad Indentation of the enclosing level.
- * @returns {string} Indented block.
- */
-function block(open, close, entries, pad) {
- if (entries.length === 0) return `${open}${close}`;
- const body = entries.map((entry) => `${pad} ${entry}`).join(',\n');
- return `${open}\n${body}\n${pad}${close}`;
-}
-
-/**
- * Serializes an arbitrary state value for display. Handles functions, blobs,
- * DOM nodes, maps, sets and circular references, which plain `JSON.stringify`
- * would drop or throw on.
- *
- * @param {*} value Value to serialize.
- * @param {number} depth Current nesting depth.
- * @param {WeakSet