diff --git a/src/components/article/ArticleSearch.jsx b/src/components/article/ArticleSearch.jsx index f3f2692..0f37b8b 100644 --- a/src/components/article/ArticleSearch.jsx +++ b/src/components/article/ArticleSearch.jsx @@ -8,7 +8,6 @@ import DialogContent from '@mui/material/DialogContent'; import DialogTitle from '@mui/material/DialogTitle'; import IconButton from '@mui/material/IconButton'; import InputAdornment from '@mui/material/InputAdornment'; -import List from '@mui/material/List'; import ListItemAvatar from '@mui/material/ListItemAvatar'; import ListItemButton from '@mui/material/ListItemButton'; import ListItemText from '@mui/material/ListItemText'; @@ -27,7 +26,6 @@ import ArticlePhoto from './ArticlePhoto'; import { BrandLabel } from '../brand'; import { formatPrice } from '../../utils/price'; import { formatStock } from './searchFormat'; -import ResultsPagination from './ResultsPagination'; import { BORDER, SCRIM_LABEL } from '../../colors'; const searchResultRowSx = { @@ -89,6 +87,186 @@ function SearchRowMeasureSample() { ); } +/** + * Ambient context for the `renderItem` row. FillSpace renders rows as a bare + * element and cannot pass page callbacks, so `renderListContent` publishes the + * current selection handler / busy state here on every render. + */ +const searchRowsContext = { busySku: null, actionLabel: 'Auswählen', onSelect: null }; + +/** + * One search-result row rendered by FillSpace via `renderItem`. A class + * component so FillSpace renders it as an element (and can probe it with + * `item` undefined to measure the row height). + */ +class SearchResultRow extends PureComponent { + render() { + const { item: article } = this.props; + const { busySku, actionLabel, onSelect } = searchRowsContext; + + // Data-free probe (`item` undefined): the fixed-height measure sample. + if (!article) return ; + + const photo = article.photos?.[0] || article.articlepic?.[0]; + const fullHash = photo?.hash || photo?.fullHash; + const thumbHash = photo?.thumbhash || photo?.thumbHash; + const thumbW = photo?.thumbw || photo?.thumbW; + const thumbH = photo?.thumbh || photo?.thumbH; + const hasPhoto = fullHash != null || thumbHash != null; + const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans; + const price = article.price ?? article.vknetto ?? article.net; + const isBusy = busySku === article.sku; + + return ( + onSelect?.(article)} + sx={searchResultRowSx} + > + + + {hasPhoto ? ( + + ) : ( + + )} + + + + + + {article.name || '–'} + + {Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && ( + + {Number(article.childCount) || 0} + + )} + + } + secondary={ + + {(article.brand?.name || article.brandName) && ( + <> + + {' · '} + + )} + {article.sku} + {article.stock != null && ` · Bestand ${formatStock(article.stock)}`} + {article.available != null && ` · Verfügbar ${formatStock(article.available)}`} + {primaryEan ? ` · EAN ${primaryEan}` : ''} + + } + slotProps={{ primary: { fontWeight: 500, noWrap: true } }} + /> + + + {price != null && ( + + {formatPrice(price)} + + )} + + + + ); + } +} + /** * Unified article search component based on articleSearchAutomat / articleIndexAutomat. * @@ -107,7 +285,6 @@ function SearchRowMeasureSample() { * @param {Array} [props.excludeSkus=[]] SKUs to exclude. * @param {boolean} [props.allowParent=false] Whether parent articles are selectable. * @param {Array} [props.categories=[]] Category IDs to filter by. - * @param {number} [props.pageSize] Initial results per page (measured dynamically by FillSpace). * @param {boolean} [props.clearOnSelect=false] * @param {boolean} [props.disabled=false] * @param {boolean} [props.autoFocus=false] @@ -120,7 +297,6 @@ function SearchRowMeasureSample() { */ export default class ArticleSearch extends PureComponent { headerRef = createRef(); - paginationRef = createRef(); listContainerRef = createRef(); contentContainerRef = createRef(); fillSpaceRef = createRef(); @@ -128,14 +304,9 @@ export default class ArticleSearch extends PureComponent { constructor(props) { super(props); const initialQuery = props.initialQuery ?? ''; - const pageSize = props.pageSize ?? 8; this.state = { query: initialQuery, offset: 0, - pageSize, - totalCount: 0, - pageCount: 1, - currentPage: 1, searchKey: 0, results: [], indexStatus: articleIndexAutomat.state.status, @@ -164,7 +335,7 @@ export default class ArticleSearch extends PureComponent { this.props.allowParent !== prevProps.allowParent || this.props.categories !== prevProps.categories ) { - this.runQuery(this.state.query, this.state.offset); + this.runQuery(this.state.query); } if (this.props.busySku !== prevProps.busySku) { @@ -173,7 +344,7 @@ export default class ArticleSearch extends PureComponent { if (this.props.initialQuery !== prevProps.initialQuery && this.props.initialQuery != null) { this.setState({ query: this.props.initialQuery, offset: 0 }); - this.runQuery(this.props.initialQuery, 0); + this.runQuery(this.props.initialQuery); } } @@ -181,52 +352,35 @@ export default class ArticleSearch extends PureComponent { this.unsubIndex?.(); } - handleMeasurement = (count) => { - if (!count || count <= 0 || count === this.state.pageSize) return; - this.setState({ pageSize: count }); - this.runQuery(this.state.query, this.state.offset, count); - }; - - runQuery = (queryText, newOffset = 0, customPageSize) => { + runQuery = (queryText) => { const { excludeSkus = [], allowParent = false, categories = [], + mode, } = this.props; - const pageSize = customPageSize ?? this.state.pageSize; - + // FillSpace paginates `results` itself, so the list/dialog modes load the + // full match set; the autocomplete popper keeps a small option cap. const res = searchArticlesIndex(queryText, { excludeSkus, allowParent, categories, - offset: newOffset, - pageSize, + offset: 0, + limit: mode === 'autocomplete' ? 20 : Infinity, }); this.setState({ results: Array.isArray(res) ? res : (res.recordset ?? []), - totalCount: res.totalCount ?? res.length, - pageCount: res.pageCount ?? 1, - currentPage: res.currentPage ?? 1, - offset: newOffset, + offset: 0, query: queryText, - pageSize, }); }; - handlePageChange = (_e, page) => { - const nextOffset = (page - 1) * this.state.pageSize; - this.runQuery(this.state.query, nextOffset); - }; - handleOpenDialog = () => { const initialQuery = this.props.initialQuery ?? ''; this.setState({ internalOpen: true, query: initialQuery, offset: 0 }); - this.runQuery(initialQuery, 0); - requestAnimationFrame(() => { - this.fillSpaceRef.current?.measure(); - }); + this.runQuery(initialQuery); }; handleCloseDialog = () => { @@ -340,19 +494,13 @@ export default class ArticleSearch extends PureComponent { actionLabel = 'Auswählen', } = this.props; - const { - query, - results, - busySku, - error, - offset, - pageSize, - totalCount, - pageCount, - currentPage, - } = this.state; + const { query, results, busySku, error } = this.state; const loading = this.state.indexStatus === 'loading'; - const showPagination = totalCount > pageSize; + + // Values the static `renderItem` row reads through the context. + searchRowsContext.busySku = busySku; + searchRowsContext.actionLabel = actionLabel; + searchRowsContext.onSelect = this.handleSelectArticle; return ( - - - - } + aboveRef={this.headerRef} + containerRef={this.contentContainerRef} + items={results} + renderItem={SearchResultRow} + minPageSize={6} minHeight={180} sx={{ overflow: 'auto', flex: 1, minHeight: 0 }} - > - {loading ? ( - - - - Artikel werden geladen… - - - ) : results.length === 0 ? null : ( - - {results.map((article) => { - const photo = article.photos?.[0] || article.articlepic?.[0]; - const fullHash = photo?.hash || photo?.fullHash; - const thumbHash = photo?.thumbhash || photo?.thumbHash; - const thumbW = photo?.thumbw || photo?.thumbW; - const thumbH = photo?.thumbh || photo?.thumbH; - const hasPhoto = fullHash != null || thumbHash != null; - const primaryEan = Array.isArray(article.eans) ? article.eans[0] : article.eans; - const price = article.price ?? article.vknetto ?? article.net; - const isBusy = busySku === article.sku; + /> - return ( - this.handleSelectArticle(article)} - sx={searchResultRowSx} - > - - - {hasPhoto ? ( - - ) : ( - - )} - - - - - - {article.name || '–'} - - {Boolean(article.isParent || article.is_parent || (Number(article.childCount) > 0)) && ( - - {Number(article.childCount) || 0} - - )} - - } - secondary={ - - {(article.brand?.name || article.brandName) && ( - <> - - {' · '} - - )} - {article.sku} - {article.stock != null && ` · Bestand ${formatStock(article.stock)}`} - {article.available != null && ` · Verfügbar ${formatStock(article.available)}`} - {primaryEan ? ` · EAN ${primaryEan}` : ''} - - } - slotProps={{ primary: { fontWeight: 500, noWrap: true } }} - /> - - - {price != null && ( - - {formatPrice(price)} - - )} - - - - ); - })} - - )} - + {loading && results.length === 0 ? ( + + + + Artikel werden geladen… + + + ) : null} ); } diff --git a/src/components/brand/ArtikelMarke.jsx b/src/components/brand/ArtikelMarke.jsx index 77eed0c..4e44789 100644 --- a/src/components/brand/ArtikelMarke.jsx +++ b/src/components/brand/ArtikelMarke.jsx @@ -128,7 +128,7 @@ class ArtikelMarke extends PureComponent { '&:hover': disabled ? undefined : { opacity: 0.8 }, }} > - + ) : ( - +/** + * Ambient context for the `renderItem` row. FillSpace renders rows as a bare + * element (``, probe: `item` + * undefined) and cannot pass page callbacks, so the page publishes them here + * on every render. + */ +const resultRowsContext = { categoryFacets: {}, onOpenArticle: null }; + +/** + * One search-result row rendered by FillSpace via `renderItem`. A class + * component so FillSpace renders it as an element (and can probe it with + * `item` undefined to measure the row height). + */ +class ResultRow extends Component { + render() { + const { item: row } = this.props; + const { categoryFacets, onOpenArticle } = resultRowsContext; + + // Data-free probe (`item` undefined): fixed-height placeholder so FillSpace + // can measure the row without touching real data. + if (!row) { + return ( + + +   - } - slotProps={{ primary: { fontWeight: 500, noWrap: true } }} - /> - + ); + } + + const isParent = Boolean(row.isParent || row.is_parent); + const childCount = Number(row.childCount) || 0; + const showChildBadge = isParent || childCount > 0; + const price = getArticleGross(row, companyAutomat.state.current?.taxrate); + + return ( + e.preventDefault()} + onClick={() => onOpenArticle?.(row)} + sx={resultRowSx} > -   - - - ); + + + {row.name} + + {showChildBadge && ( + + {childCount} + + )} + + } + secondary={ + + {(row.brand?.name || row.brandName) && ( + <> + + {' · '} + + )} + {row.sku} + {' · '} + Bestand {formatStock(row.stock)} + {' · '} + Verfügbar {formatStock(row.available)} + {row.categories + ? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}` + : ''} + + } + slotProps={{ primary: { fontWeight: 500, noWrap: true } }} + /> + + {price != null ? formatPrice(price) : '—'} + + + ); + } } // #endregion @@ -172,30 +296,19 @@ class ArtikelSearchPage extends Component { constructor(props) { super(props); this.state = initialSearchState(props.searchParams); - this.paginationRef = createRef(); this.filterHeaderRef = createRef(); this.fillSpaceRef = createRef(); - this.categoryFillSpaceRef = createRef(); } // #endregion - // #region Measurement & Query Persistence + // #region Query Persistence toggleCategoryFilter = () => { this.setState((prev) => ({ categoryFilterOpen: !prev.categoryFilterOpen })); }; - handleMeasurement = (count) => { - if (!count || count === this.state.pageSize) return; - this.setState({ pageSize: count }); - const { filter, offset } = queryFromParams(this.props.searchParams); - const activeTarget = this.state.input; - setLastSearchTerm(activeTarget, buildSearchUrl(activeTarget, filter, offset)); - void articleSearchAutomat.actions.search({ - target: activeTarget, - filter, - offset, - pageSize: count, - }).catch(() => {}); + handleOpenArticle = (row) => { + this.persistQuery(); + this.props.navigate(artikelDetailPath(row)); }; persistQuery = () => { @@ -238,8 +351,7 @@ class ArtikelSearchPage extends Component { void articleSearchAutomat.actions.search({ target: activeTarget, filter, - offset, - pageSize: this.state.pageSize, + offset: 0, }).catch(() => {}); } @@ -253,22 +365,12 @@ class ArtikelSearchPage extends Component { setLastSearchTerm(target, buildSearchUrl(target, query.filter, query.offset)); } - componentDidUpdate(prevProps, prevState) { + componentDidUpdate(prevProps) { const { searchParams } = this.props; const prev = prevProps.searchParams; const target = searchParams.get('target') ?? ''; const prevTarget = prev.get('target') ?? ''; - if ( - this.state.categoryFilterOpen !== prevState?.categoryFilterOpen || - searchParams.get('filter') !== prev.get('filter') - ) { - requestAnimationFrame(() => { - this.fillSpaceRef.current?.measure(); - this.categoryFillSpaceRef.current?.measure(); - }); - } - if (target !== prevTarget && target !== this.state.input) { this.setState({ input: target }); this.fetchResults(); @@ -316,7 +418,6 @@ class ArtikelSearchPage extends Component { const value = event.target.value; this.setState({ input: value }); const { filter } = queryFromParams(this.props.searchParams); - const { pageSize } = this.state; const currentUrl = buildSearchUrl(value, filter, 0); setLastSearchTerm(value, currentUrl); writeArtikelQuery({ target: value, filter, offset: 0 }); @@ -325,7 +426,6 @@ class ArtikelSearchPage extends Component { target: value, filter, offset: 0, - pageSize: pageSize ?? undefined, }).catch(() => {}); }; @@ -335,7 +435,6 @@ class ArtikelSearchPage extends Component { fetchResults = () => { const { target, filter, offset } = queryFromParams(this.props.searchParams); - const { pageSize } = this.state; writeArtikelQuery({ target, filter, offset }); @@ -343,17 +442,8 @@ class ArtikelSearchPage extends Component { target: target.trim(), filter, offset, - pageSize: pageSize ?? undefined, }).catch(() => {}); }; - - handlePageChange = (_e, page) => { - const { pageSize } = this.state; - const safePageSize = pageSize ?? 1; - const nextOffset = (page - 1) * safePageSize; - const filterParam = this.props.searchParams.get('filter') ?? ''; - this.updateParams(this.state.input, filterParam, nextOffset); - }; // #endregion // #region Render @@ -362,16 +452,11 @@ class ArtikelSearchPage extends Component { input, categoryFilterOpen, error, - categoryFacets, totalCount, + categoryFacets, recordset, - pageSize, - pageCount, - currentPage, - offset: stateOffset, } = this.state; const filterParam = this.props.searchParams.get('filter') ?? ''; - const offset = Math.max(0, Number(this.props.searchParams.get('offset')) || stateOffset || 0); const categories = parseFilter(filterParam); const rows = recordset ?? []; @@ -408,8 +493,9 @@ class ArtikelSearchPage extends Component { }) : []; - const showPagination = - rows.length > 0 && pageSize != null && totalCount != null && totalCount > pageSize; + // Per-render values the static `renderItem` row reads through the context. + resultRowsContext.categoryFacets = categoryFacets ?? {}; + resultRowsContext.onOpenArticle = this.handleOpenArticle; return ( <> @@ -502,10 +588,13 @@ class ArtikelSearchPage extends Component { {categoryFilterOpen && facetEntries.length > 0 && ( - - + )} @@ -575,150 +664,26 @@ class ArtikelSearchPage extends Component { Keine Artikel gefunden. )} - {/* Always mount real pagination height (invisible until needed) so pageSize is correct. */} - - - - - } + {/* Paper surface like /artikel/marken (PanelTile): the FillSpace + pagination header + rows render on an elevated card. */} + - - {rows.length > 0 && ( - - {rows.map((row, index) => { - const isParent = Boolean(row.isParent || row.is_parent); - const childCount = Number(row.childCount) || 0; - const showChildBadge = isParent || childCount > 0; - const price = getArticleGross(row, companyAutomat.state.current?.taxrate); - return ( - e.preventDefault()} - onClick={() => { - this.persistQuery(); - this.props.navigate(artikelDetailPath(row)); - }} - sx={resultRowSx} - > - - - {row.name} - - {showChildBadge && ( - - {childCount} - - )} - - } - secondary={ - - {(row.brand?.name || row.brandName) && ( - <> - - {' · '} - - )} - {row.sku} - {' · '} - Bestand {formatStock(row.stock)} - {' · '} - Verfügbar {formatStock(row.available)} - {row.categories - ? ` · ${formatCategoryList(row.categories, categoryFacets ?? {})}` - : ''} - - } - slotProps={{ primary: { fontWeight: 500, noWrap: true } }} - /> - - {price != null ? formatPrice(price) : '—'} - - - ); - })} - - )} - - + + diff --git a/src/utils/thumbHashCache.js b/src/utils/thumbHashCache.js new file mode 100644 index 0000000..13c2ad8 --- /dev/null +++ b/src/utils/thumbHashCache.js @@ -0,0 +1,49 @@ +import { thumbHashToDataURL } from 'thumbhash'; + +// Module-scoped caches are also stashed on `globalThis` so they survive Vite +// HMR reloads instead of re-decoding / re-fetching every image. +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()); + +/** True once the full image for `fullHash` has been loaded (data URL or ``). */ +export function isImageLoaded(fullHash) { + if (!fullHash) return false; + return loadedImageHashes.has(fullHash) || imageCache.has(fullHash); +} + +/** Records that `fullHash` is loaded, optionally caching the decoded ``. */ +export function markImageLoaded(fullHash, img) { + if (!fullHash) return; + loadedImageHashes.add(fullHash); + if (img) { + imageCache.set(fullHash, img); + } +} + +/** Returns the cached, already-decoded `` for `fullHash`, or `null`. */ +export function getCachedImage(fullHash) { + if (!fullHash) return null; + const img = imageCache.get(fullHash); + if (img && img.complete && img.naturalWidth) { + return img; + } + return null; +} + +/** Decodes a ThumbHash string into a data URL (cached per hash). */ +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('[thumbHashCache] thumbHashToDataURL failed:', err); + return null; + } + } + return url; +}