u
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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(() => {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user