This commit is contained in:
seb
2026-10-04 09:21:45 +02:00
parent 4edd417dab
commit da318223c1
8 changed files with 315 additions and 166 deletions

View File

@@ -34,6 +34,36 @@ const initialState = {
};
let categoryAutomat;
let _treeLoadingPromise = null;
function waitForCategoryTree() {
if (categoryAutomat.state.treeStatus === 'succeeded') {
return Promise.resolve(categoryAutomat.state);
}
return new Promise((resolve, reject) => {
let unsubCat = null;
let unsubAuth = null;
const cleanup = () => {
unsubCat?.();
unsubAuth?.();
};
unsubCat = categoryAutomat.subscribe((state) => {
if (state.treeStatus === 'succeeded') {
cleanup();
resolve(state);
} else if (state.treeStatus === 'failed') {
cleanup();
reject(new Error(state.error || 'Fehler beim Laden des Kategoriebaums'));
}
});
unsubAuth = authAutomat.subscribe((auth) => {
if (auth.status === 'anon') {
cleanup();
reject(new Error('Nicht authentifiziert.'));
}
});
});
}
/**
* KeyedAutomat holding per-route category views indexed by slug path.
@@ -50,7 +80,10 @@ export const categoryViews = new KeyedAutomat(
// Ensure tree is loaded before resolving slug -> categoryId
if (categoryAutomat.state.treeStatus !== 'succeeded') {
await categoryAutomat.actions.loadTree();
if (categoryAutomat.state.treeStatus === 'idle') {
void categoryAutomat.actions.loadTree();
}
await waitForCategoryTree();
}
const { categories, rootCategoryId } = categoryAutomat.state;
@@ -117,31 +150,43 @@ export function disposeAllCategoryViewAutomats() {
const actions = {
/** Load the category tree for the currently selected company. */
async loadTree() {
const company = companyAutomat.state.current;
let company = companyAutomat.state.current;
if (!company && companyAutomat.state.status === 'idle') {
await companyAutomat.actions.load();
company = companyAutomat.state.current;
}
if (!company) return;
const { id: companyId, rootCategoryId: companyRootId } = company;
if (!companyRootId) return;
if (_treeLoadingPromise) return _treeLoadingPromise;
categoryAutomat.setState({ treeStatus: 'loading', error: null });
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
});
refreshLoadedCategoryViewChildren(parsed.categories);
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
_treeLoadingPromise = (async () => {
try {
const data = await categoryApi.getTree();
const parsed = parseCategoryTreeResponse(data, companyRootId);
categoryAutomat.setState({
treeStatus: 'succeeded',
companyId,
languageId: parsed.languageId,
categories: parsed.categories,
rootCategoryId: parsed.rootCategoryId,
error: null,
});
refreshLoadedCategoryViewChildren(parsed.categories);
} catch (err) {
categoryAutomat.setState({
treeStatus: 'failed',
error: err?.message ?? String(err),
});
throw err;
}
})().finally(() => {
_treeLoadingPromise = null;
});
return _treeLoadingPromise;
},
/** Toggle rename/edit mode for the category grid. */
@@ -429,8 +474,11 @@ companyAutomat.subscribe((company) => {
const id = company.current?.id ?? null;
const rootCategoryId = company.current?.rootCategoryId ?? null;
if (id && rootCategoryId && id !== _lastCompanyId) {
const isCompanySwitch = _lastCompanyId !== null;
_lastCompanyId = id;
disposeAllCategoryViewAutomats();
if (isCompanySwitch) {
disposeAllCategoryViewAutomats();
}
void categoryAutomat.actions.loadTree();
}
if (!id && _lastCompanyId) {

View File

@@ -1,6 +1,48 @@
import { useEffect, useRef } from 'react';
import Box from '@mui/material/Box';
import { thumbHashToRGBA } from 'thumbhash';
import { thumbHashToRGBA, thumbHashToDataURL } from 'thumbhash';
const dataUrlCache = (globalThis.__THUMB_DATA_URL_CACHE__ ??= new Map());
const loadedImageHashes = (globalThis.__LOADED_IMAGE_HASHES__ ??= new Set());
export const imageCache = (globalThis.__IMAGE_CACHE__ ??= new Map());
export function isImageLoaded(fullHash) {
if (!fullHash) return false;
return loadedImageHashes.has(fullHash) || imageCache.has(fullHash);
}
export function markImageLoaded(fullHash, img) {
if (!fullHash) return;
loadedImageHashes.add(fullHash);
if (img) {
imageCache.set(fullHash, img);
}
}
export function getCachedImage(fullHash) {
if (!fullHash) return null;
const img = imageCache.get(fullHash);
if (img && img.complete && img.naturalWidth) {
return img;
}
return null;
}
export function getThumbHashDataUrl(thumbHash) {
if (!thumbHash || typeof thumbHash !== 'string') return null;
let url = dataUrlCache.get(thumbHash);
if (!url) {
try {
const bytes = Uint8Array.from(atob(thumbHash), (c) => c.charCodeAt(0));
url = thumbHashToDataURL(bytes);
dataUrlCache.set(thumbHash, url);
} catch (err) {
console.warn('[ThumbHashImage] thumbHashToDataURL failed:', err);
return null;
}
}
return url;
}
export function decodeThumbHash(thumbHash) {
if (!thumbHash || typeof thumbHash !== 'string') return null;

View File

@@ -6,7 +6,7 @@ import Typography from '@mui/material/Typography';
import { Tile, tileBaseSx } from 'dashgui/tiles';
import { formatPrice } from '../../utils/price';
import { articleSku } from '../article/articleRoute';
import { decodeThumbHash } from '../article/ThumbHashImage';
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
import { BrandLabel } from '../brand';
import { thumb } from '../../api';
@@ -100,12 +100,22 @@ class ArticleTileBackdrop extends PureComponent {
img = null;
observer = null;
constructor(props) {
super(props);
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
this.img = getCachedImage(props.fullHash);
}
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();
}
}
@@ -118,8 +128,15 @@ class ArticleTileBackdrop extends PureComponent {
loadImage() {
const { fullHash } = this.props;
if (fullHash) {
const cached = getCachedImage(fullHash);
if (cached) {
this.img = cached;
this.paint();
return;
}
const img = new Image();
img.onload = () => {
imageCache.set(fullHash, img);
if (this.img === img) {
this.paint();
}
@@ -127,15 +144,18 @@ class ArticleTileBackdrop extends PureComponent {
img.src = thumb.url(fullHash);
this.img = img;
if (img.complete && img.naturalWidth) {
imageCache.set(fullHash, img);
this.paint();
return;
}
}
this.paint();
}
setCanvas = (canvas) => {
this.canvasRef = canvas;
if (!this.img && this.props.fullHash) {
this.img = getCachedImage(this.props.fullHash);
}
if (canvas && typeof ResizeObserver !== 'undefined') {
this.observer?.disconnect();
this.observer = new ResizeObserver(() => this.paint());
@@ -157,8 +177,9 @@ class ArticleTileBackdrop extends PureComponent {
const ctx = canvas.getContext('2d');
if (!ctx) return;
const img = this.img;
const img = this.img || getCachedImage(this.props.fullHash);
if (img && img.complete && img.naturalWidth && img.naturalHeight) {
this.img = img;
drawContainedWithStretchedEdges(
ctx,
img,
@@ -167,41 +188,49 @@ class ArticleTileBackdrop extends PureComponent {
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() {
const hasCachedImage = Boolean(this.img && this.img.complete && this.img.naturalWidth);
return (
<Box
component="canvas"
ref={this.setCanvas}
aria-hidden
sx={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
display: 'block',
overflow: 'hidden',
}}
/>
>
{!hasCachedImage && this.thumbUrl ? (
<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>
);
}
}

View File

@@ -9,7 +9,7 @@ import SettingsIcon from '@mui/icons-material/Settings';
import PointOfSaleIcon from '@mui/icons-material/PointOfSale';
import PhotoCameraIcon from '@mui/icons-material/PhotoCamera';
import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined';
import ThumbHashImage from '../article/ThumbHashImage';
import { getThumbHashDataUrl, getCachedImage, imageCache } from '../article/ThumbHashImage';
import { thumb } from '../../api';
import { tileBaseSx } from 'dashgui/tiles';
@@ -195,36 +195,56 @@ function MiddleBand({ settingsTab, nameInput, terminals, categoryPosterminalIds,
* ThumbHash is shown immediately as a placeholder; the thumb fades in once loaded.
*/
class CategoryBackdrop extends PureComponent {
state = { thumbLoaded: false };
constructor(props) {
super(props);
this.thumbUrl = getThumbHashDataUrl(props.thumbHash);
this.isCached = Boolean(getCachedImage(props.fullHash));
}
componentDidUpdate(prevProps) {
if (prevProps.thumbHash !== this.props.thumbHash) {
this.thumbUrl = getThumbHashDataUrl(this.props.thumbHash);
}
if (prevProps.fullHash !== this.props.fullHash) {
this.setState({ thumbLoaded: false });
this.isCached = Boolean(getCachedImage(this.props.fullHash));
this.forceUpdate();
}
}
handleThumbLoad = () => {
this.setState({ thumbLoaded: true });
};
handleThumbError = () => {
this.setState({ thumbLoaded: false });
handleLoad = (e) => {
if (this.props.fullHash && e.target) {
imageCache.set(this.props.fullHash, e.target);
}
};
render() {
const { thumbHash, fullHash } = this.props;
const { thumbLoaded } = this.state;
if (!thumbHash && !fullHash) return null;
const { fullHash } = this.props;
if (!this.thumbUrl && !fullHash) return null;
const isCached = this.isCached || Boolean(getCachedImage(fullHash));
return (
<Box sx={{ position: 'absolute', inset: 0, zIndex: 0, overflow: 'hidden' }}>
{thumbHash && !thumbLoaded ? <ThumbHashImage thumbHash={thumbHash} /> : null}
{fullHash ? (
{!isCached && this.thumbUrl ? (
<Box
component="img"
src={thumb.url(fullHash)}
src={this.thumbUrl}
alt=""
onLoad={this.handleThumbLoad}
onError={this.handleThumbError}
aria-hidden
sx={{
position: 'absolute',
inset: 0,
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
}}
/>
) : null}
{fullHash ? (
<Box
component="img"
src={thumb.url(fullHash)}
alt=""
onLoad={this.handleLoad}
sx={{
position: 'absolute',
inset: 0,
@@ -232,7 +252,6 @@ class CategoryBackdrop extends PureComponent {
width: '100%',
height: '100%',
objectFit: 'cover',
opacity: thumbLoaded ? 1 : 0,
}}
/>
) : null}

View File

@@ -7,6 +7,7 @@ import PeopleIcon from '@mui/icons-material/People';
import InfoIcon from '@mui/icons-material/Info';
import ViewListIcon from '@mui/icons-material/ViewList';
import CategoryIcon from '@mui/icons-material/Category';
import GridViewIcon from '@mui/icons-material/GridView';
import WarehouseIcon from '@mui/icons-material/Warehouse';
import CopyrightIcon from '@mui/icons-material/Copyright';
import ListAltIcon from '@mui/icons-material/ListAlt';
@@ -16,7 +17,6 @@ import StorefrontIcon from '@mui/icons-material/Storefront';
import RequestQuoteIcon from '@mui/icons-material/RequestQuote';
import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
import LabelIcon from '@mui/icons-material/Label';
import TuneIcon from '@mui/icons-material/Tune';
import HistoryIcon from '@mui/icons-material/History';
import PublicIcon from '@mui/icons-material/Public';
import BarChartIcon from '@mui/icons-material/BarChart';
@@ -142,7 +142,7 @@ export const SUB_NAV = {
segment: 'kategorien',
label: 'Kategorieen',
title: 'Kategorien',
icon: CategoryIcon,
icon: GridViewIcon,
help: 'Hierarchischer Kategoriebaum zur Sortimentsgliederung',
},
{
@@ -167,7 +167,7 @@ export const SUB_NAV = {
segment: 'merkmale',
label: 'Merkmale',
title: 'Merkmale & Attribute',
icon: TuneIcon,
icon: CategoryIcon,
help: 'Globale Merkmale, Attribute und Werte verwalten',
},
],

View File

@@ -238,11 +238,22 @@ class ArtikelKategorienPage extends PureComponent {
routeError: view?.error ?? null,
});
this.subscribeViewHandle();
} else if (resolvedId !== resolvedCategoryId) {
this.setState({
resolvedCategoryId: resolvedId,
childCategories: getCategoryChildren(categories, resolvedId),
});
} else {
const view = this.handle?.state;
const updates = {};
if (resolvedId !== resolvedCategoryId) {
updates.resolvedCategoryId = resolvedId;
updates.childCategories = view?.data?.childCategories ?? getCategoryChildren(categories, resolvedId);
}
if (view?.data?.articles && view.data.articles !== this.state.articles) {
updates.articles = view.data.articles;
}
if (Object.keys(updates).length > 0) {
this.setState(updates);
}
if (!this.handle?.loaded && !this.handle?.loading) {
void this.handle?.load().catch(() => {});
}
}
};