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;
+}