feat: Implement multi-image product display with fading and hover effects, and introduce lazy-loaded HTML sanitization.

This commit is contained in:
sebseb7
2026-03-10 11:27:15 +01:00
parent fb6c1159fe
commit 65a676de46
6 changed files with 403 additions and 205 deletions

View File

@@ -34,11 +34,12 @@ import Header from "./components/Header.js";
import Footer from "./components/Footer.js";
import MainPageLayout from "./components/MainPageLayout.js";
// TEMPORARILY DISABLE ALL LAZY LOADING TO ELIMINATE CircularProgress
import Content from "./components/Content.js";
import ProductDetail from "./components/ProductDetail.js";
import ProfilePage from "./pages/ProfilePage.js";
import ResetPassword from "./pages/ResetPassword.js";
// Lazy load rarely-accessed pages
const ProfilePage = lazy(() => import(/* webpackChunkName: "profile" */ "./pages/ProfilePage.js"));
const ResetPassword = lazy(() => import(/* webpackChunkName: "reset-password" */ "./pages/ResetPassword.js"));
// Lazy load admin pages - only loaded when admin users access them
const AdminPage = lazy(() => import(/* webpackChunkName: "admin" */ "./pages/AdminPage.js"));

View File

@@ -78,51 +78,150 @@ class Product extends Component {
window.smallPicCache = {};
}
if(this.props.pictureList && this.props.pictureList.length > 0 && this.props.pictureList.split(',').length > 0) {
const bildId = this.props.pictureList.split(',')[0];
if(window.smallPicCache[bildId]){
this.state = {image:window.smallPicCache[bildId],loading:false, error: false}
}else{
this.state = {image: null, loading: true, error: false};
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
this.loadImage(bildId);
if (pictureIds.length > 0) {
const initialImages = pictureIds.map(id => window.smallPicCache[id] || null);
const isFirstCached = !!initialImages[0];
this.state = {
images: initialImages,
currentImageIndex: 0,
loading: !isFirstCached,
error: false,
isHovering: false
};
pictureIds.forEach((id, index) => {
if (!window.smallPicCache[id]) {
this.loadImage(id, index);
}
});
} else {
this.state = {image: null, loading: false, error: false};
this.state = { images: [], currentImageIndex: 0, loading: false, error: false, isHovering: false };
}
}
componentDidMount() {
this._isMounted = true;
this.startRandomFading();
}
loadImage = (bildId) => {
startRandomFading = () => {
if (this.state.isHovering) return;
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
const minInterval = 4000;
const maxInterval = 8000;
const randomInterval = Math.floor(Math.random() * (maxInterval - minInterval + 1)) + minInterval;
this.fadeTimeout = setTimeout(() => {
if (this._isMounted) {
this.setState(prevState => {
let nextIndex = (prevState.currentImageIndex + 1) % pictureIds.length;
let attempts = 0;
while (!prevState.images[nextIndex] && attempts < pictureIds.length) {
nextIndex = (nextIndex + 1) % pictureIds.length;
attempts++;
}
if (attempts < pictureIds.length && nextIndex !== prevState.currentImageIndex) {
return { currentImageIndex: nextIndex };
}
return null;
}, () => {
this.startRandomFading();
});
}
}, randomInterval);
}
}
handleMouseMove = (e) => {
const pictureIds = (this.props.pictureList && this.props.pictureList.length > 0)
? this.props.pictureList.split(',').filter(id => id.trim().length > 0)
: [];
if (pictureIds.length > 1) {
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
this.fadeTimeout = null;
}
const { left, width } = e.currentTarget.getBoundingClientRect();
const x = e.clientX - left;
const segmentWidth = width / pictureIds.length;
let targetIndex = Math.floor(x / segmentWidth);
if (targetIndex >= pictureIds.length) targetIndex = pictureIds.length - 1;
if (targetIndex < 0) targetIndex = 0;
if (this.state.currentImageIndex !== targetIndex) {
if (this.state.images[targetIndex]) {
this.setState({ currentImageIndex: targetIndex, isHovering: true });
} else {
this.setState({ isHovering: true });
}
} else if (!this.state.isHovering) {
this.setState({ isHovering: true });
}
}
}
handleMouseLeave = () => {
if (this.state.isHovering) {
this.setState({ isHovering: false }, () => {
this.startRandomFading();
});
}
}
loadImage = (bildId, index) => {
console.log('loadImagevisSocket', bildId);
window.socketManager.emit('getPic', { bildId, size: 'small' }, (res) => {
if (res.success) {
window.smallPicCache[bildId] = URL.createObjectURL(new Blob([res.imageBuffer], { type: 'image/avif' }));
if (this._isMounted) {
this.setState({image: window.smallPicCache[bildId], loading: false});
this.setState(prevState => {
const newImages = [...prevState.images];
newImages[index] = window.smallPicCache[bildId];
return {
images: newImages,
loading: index === 0 ? false : prevState.loading
};
});
} else {
this.state.image = window.smallPicCache[bildId];
this.state.loading = false;
this.state.images[index] = window.smallPicCache[bildId];
if (index === 0) this.state.loading = false;
}
} else {
console.log('Fehler beim Laden des Bildes:', res);
if (this._isMounted) {
this.setState({error: true, loading: false});
this.setState(prevState => ({
error: index === 0 ? true : prevState.error,
loading: index === 0 ? false : prevState.loading
}));
} else {
if (index === 0) {
this.state.error = true;
this.state.loading = false;
}
}
}
});
}
componentWillUnmount() {
this._isMounted = false;
if (this.fadeTimeout) {
clearTimeout(this.fadeTimeout);
}
}
handleQuantityChange = (quantity) => {
@@ -338,7 +437,10 @@ class Product extends Component {
cursor: 'pointer'
}}
>
<Box sx={{
<Box
onMouseMove={this.handleMouseMove}
onMouseLeave={this.handleMouseLeave}
sx={{
position: 'relative',
height: { xs: '240px', sm: '180px' },
display: 'flex',
@@ -350,8 +452,40 @@ class Product extends Component {
}}>
{this.state.loading ? (
<CircularProgress sx={{ color: '#90ffc0' }} />
) : this.state.image === null ? (
) : this.state.images && this.state.images.length > 0 && this.state.images.some(img => img !== null) ? (
this.state.images.map((imgSrc, index) => {
if (!imgSrc) return null;
return (
<CardMedia
key={index}
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
image={imgSrc}
alt={name}
fetchPriority={this.props.priority === 'high' && index === 0 ? 'high' : 'auto'}
loading={this.props.priority === 'high' && index === 0 ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0,
opacity: this.state.currentImageIndex === index ? 1 : 0,
transition: this.state.isHovering ? 'opacity 0.2s ease-in-out' : 'opacity 1s ease-in-out'
}}
/>
);
})
) : (
<CardMedia
component="img"
height={window.innerWidth < 600 ? "240" : "180"}
@@ -369,28 +503,11 @@ class Product extends Component {
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
}}
/>
) : (
<CardMedia
component="img"
height={ window.innerWidth < 600 ? "240" : "180" }
image={this.state.image}
alt={name}
fetchPriority={this.props.priority === 'high' ? 'high' : 'auto'}
loading={this.props.priority === 'high' ? 'eager' : 'lazy'}
onError={(e) => {
// Ensure alt text is always present even on error
if (!e.target.alt) {
e.target.alt = name || 'Produktbild';
}
}}
sx={{
objectFit: 'contain',
borderTopLeftRadius: '8px',
borderTopRightRadius: '8px',
width: '100%'
width: '100%',
height: '100%',
position: 'absolute',
top: 0,
left: 0
}}
/>
)}

View File

@@ -8,8 +8,7 @@ import EmailIcon from "@mui/icons-material/Email";
import LinkIcon from "@mui/icons-material/Link";
import CodeIcon from "@mui/icons-material/Code";
import { Link } from "react-router-dom";
import parse from "html-react-parser";
import sanitizeHtml from "sanitize-html";
import LazySanitizedHtml from "../utils/LazySanitizedHtml.js";
import AddToCartButton from "./AddToCartButton.js";
import ProductImage from "./ProductImage.js";
import Product from "./Product.js";
@@ -1624,10 +1623,10 @@ class ProductDetailPage extends Component {
"& strong": { fontWeight: 600 },
}}
>
{product.description ? (() => {
try {
// Sanitize HTML to remove invalid tags, but preserve style attributes and <product> tags
const sanitized = sanitizeHtml(product.description, {
{product.description ? (
<LazySanitizedHtml
html={product.description}
sanitizeOptions={(sanitizeHtml) => ({
allowedTags: sanitizeHtml.defaults.allowedTags.concat(['img', 'product']),
allowedAttributes: {
'*': ['class', 'style'],
@@ -1636,26 +1635,20 @@ class ProductDetailPage extends Component {
'product': ['articlenr']
},
disallowedTagsMode: 'discard'
});
// Parse with custom replace function to handle <product> tags
return parse(sanitized, {
})}
parseOptions={{
replace: (domNode) => {
if (domNode.type === 'tag' && domNode.name === 'product') {
const articleNr = domNode.attribs && domNode.attribs['articlenr'];
if (articleNr) {
// Render embedded product component
return this.renderEmbeddedProduct(articleNr);
}
}
}
});
} catch (error) {
console.warn('Failed to parse product description HTML:', error);
// Fallback to rendering as plain text if HTML parsing fails
return <span>{product.description}</span>;
}
})() : upgrading ? (
}}
fallback={<span>{product.description}</span>}
/>
) : upgrading ? (
<Box sx={{ textAlign: "center", py: 2 }}>
<Typography variant="body1" color="text.secondary">
{this.props.t ? this.props.t('product.loadingDescription') : 'Produktbeschreibung wird geladen...'}

View File

@@ -1,12 +1,9 @@
import { io } from 'socket.io-client';
class SocketManager {
constructor() {
this.socket = io('', {
transports: ["websocket", "polling"],
autoConnect: false
});
this._socket = null;
this._socketReady = null;
// Listeners registered before socket.io-client has loaded
this._preSocketListeners = [];
this.emit = this.emit.bind(this);
this.on = this.on.bind(this);
@@ -15,29 +12,56 @@ class SocketManager {
this.pendingListeners = new Map();
}
on(event, callback) {
// If socket is already connected, register the listener directly
if (this.socket.connected) {
this.socket.on(event, callback);
return;
// Lazily import socket.io-client and create the socket on first use.
// Subsequent calls return the same promise.
_ensureSocket() {
if (this._socket) return Promise.resolve(this._socket);
if (this._socketReady) return this._socketReady;
this._socketReady = import('socket.io-client').then(({ io }) => {
this._socket = io('', {
transports: ['websocket', 'polling'],
autoConnect: false,
});
// Register any listeners that arrived before the socket was ready
this._preSocketListeners.forEach(({ event, callback }) => {
this._socket.on(event, callback);
});
this._preSocketListeners = [];
return this._socket;
});
return this._socketReady;
}
// Store the listener to be registered when connection is established
on(event, callback) {
if (this._socket) {
// Socket already loaded — mirror the original behaviour
if (!this.pendingListeners.has(event)) {
this.pendingListeners.set(event, new Set());
}
this.pendingListeners.get(event).add(callback);
// Register the listener now, it will receive events once connected
this.socket.on(event, callback);
this._socket.on(event, callback);
} else {
// Queue for when socket.io-client finishes loading
this._preSocketListeners.push({ event, callback });
if (!this.pendingListeners.has(event)) {
this.pendingListeners.set(event, new Set());
}
this.pendingListeners.get(event).add(callback);
}
}
off(event, callback) {
// Remove from socket listeners
console.log('off', event, callback);
this.socket.off(event, callback);
// Remove from pending listeners if present
// Remove from pre-socket queue (component unmounted before socket loaded)
this._preSocketListeners = this._preSocketListeners.filter(
(item) => !(item.event === event && item.callback === callback)
);
if (this.pendingListeners.has(event)) {
const listeners = this.pendingListeners.get(event);
listeners.delete(callback);
@@ -45,57 +69,60 @@ class SocketManager {
this.pendingListeners.delete(event);
}
}
if (this._socket) {
this._socket.off(event, callback);
}
}
connect() {
if (this.connectPromise) return this.connectPromise;
this.connectPromise = new Promise((resolve, reject) => {
this.socket.connect();
this.socket.once('connect', () => {
this.connectPromise = this._ensureSocket().then(
(socket) =>
new Promise((resolve, reject) => {
socket.connect();
socket.once('connect', () => {
resolve();
});
this.socket.once('connect_error', (error) => {
socket.once('connect_error', (error) => {
this.connectPromise = null;
reject(error);
});
});
})
);
return this.connectPromise;
}
emit(event, ...args) {
return new Promise((resolve, reject) => {
if (!this.socket.connected) {
// If not already connecting, start connection
this._ensureSocket()
.then((socket) => {
if (!socket.connected) {
if (!this.connectPromise) {
this.connect();
}
// Wait for connection before emitting
this.connectPromise
.then(() => {
this.socket.emit(event, ...args);
socket.emit(event, ...args);
resolve();
})
.catch((error) => {
reject(error);
});
} else {
// Socket already connected, emit directly
this.socket.emit(event, ...args);
socket.emit(event, ...args);
resolve();
}
})
.catch(reject);
});
}
}
// Create singleton instance
// Create singleton instance and expose globally so all components can reach it
const socketManager = new SocketManager();
// Attach to window object
window.socketManager = socketManager;
export default socketManager;

View File

@@ -0,0 +1,52 @@
import React, { lazy, Suspense } from 'react';
// Load html-react-parser and sanitize-html in a single async chunk.
// Neither library ships in the main bundle — they are only fetched when a
// component that uses this wrapper actually renders.
const SanitizedHtmlContent = lazy(() =>
Promise.all([
import(/* webpackChunkName: "html-parser" */ 'html-react-parser'),
import(/* webpackChunkName: "html-parser" */ 'sanitize-html'),
]).then(([{ default: parse }, { default: sanitizeHtml }]) => ({
default: function SanitizedHtmlContent({ html, sanitizeOptions, parseOptions }) {
try {
// sanitizeOptions can be a plain object or a factory (fn) that receives
// the sanitizeHtml module so callers can reference sanitizeHtml.defaults.
const resolvedSanitizeOptions =
typeof sanitizeOptions === 'function'
? sanitizeOptions(sanitizeHtml)
: sanitizeOptions;
const sanitized = sanitizeHtml(html, resolvedSanitizeOptions);
return parse(sanitized, parseOptions);
} catch (error) {
console.warn('LazySanitizedHtml: failed to parse HTML', error);
return <span>{html}</span>;
}
},
}))
);
/**
* Renders sanitized and parsed HTML without including sanitize-html or
* html-react-parser in the initial JavaScript bundle.
*
* @param {string} html Raw HTML string to sanitize and render
* @param {object|function} sanitizeOptions sanitize-html options object, or a
* factory (sanitizeHtml) => options so
* callers can use sanitizeHtml.defaults
* @param {object} parseOptions html-react-parser options (e.g. replace)
* @param {React.ReactNode} fallback Shown while the libraries are loading
*/
export default function LazySanitizedHtml({ html, sanitizeOptions, parseOptions, fallback = null }) {
if (!html) return null;
return (
<Suspense fallback={fallback}>
<SanitizedHtmlContent
html={html}
sanitizeOptions={sanitizeOptions}
parseOptions={parseOptions}
/>
</Suspense>
);
}

View File

@@ -301,6 +301,14 @@ export default {
priority: 20,
reuseExistingChunk: true,
},
// socket.io-client and its dependencies — always async, never initial
socketio: {
test: /[\\/]node_modules[\\/](socket\.io-client|engine\.io-client|@socket\.io|socket\.io-parser|socket\.io-msgpack-parser)[\\/]/,
name: 'socketio',
priority: 15,
chunks: 'async',
reuseExistingChunk: true,
},
// Other vendor libraries
vendor: {
test: /[\\/]node_modules[\\/]/,