Project-Image-Uploader/frontend/src/Components/Pages/SlideshowPage.js
matthias.lotz 57ce0ff2aa feat: Slideshow optimization with intelligent preloading and chronological sorting
- Add intelligent image preloading (useImagePreloader hook)
- Eliminate duplicate image display issue
- Remove visible loading delays in slideshow
- Implement chronological group sorting (year → upload date)
- Add cache management with LRU strategy (max 10 images)
- Add 3s timeout for slow connections with graceful fallback
- Add debug logging in development mode

Performance improvements:
- 0ms load time for pre-cached images (vs 200-1500ms before)
- Seamless transitions with no visual artifacts
- Better UX on production servers with slower internet

Fixes:
- Fixed: Duplicate image display in slideshow (network latency)
- Fixed: Flickering transitions between images
- Fixed: Random group order replaced with chronological

Files changed:
- NEW: frontend/src/hooks/useImagePreloader.js
- MODIFIED: frontend/src/Components/Pages/SlideshowPage.js
- UPDATED: README.md, CHANGELOG.md, docs/FEATURE_PLAN-preload-image.md
2025-11-09 13:23:27 +01:00

317 lines
9.5 KiB
JavaScript

import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Typography,
Box,
CircularProgress,
IconButton
} from '@mui/material';
import {
Home as HomeIcon,
ExitToApp as ExitIcon
} from '@mui/icons-material';
// Utils
import { fetchAllGroups } from '../../Utils/batchUpload';
import { getImageSrc } from '../../Utils/imageUtils';
// Custom Hooks
import useImagePreloader from '../../hooks/useImagePreloader';
// Styles moved inline to sx props below
function SlideshowPage() {
const navigate = useNavigate();
const [allGroups, setAllGroups] = useState([]);
const [currentGroupIndex, setCurrentGroupIndex] = useState(0);
const [currentImageIndex, setCurrentImageIndex] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [fadeOut, setFadeOut] = useState(false);
// Slideshow-Timing Konstanten
const IMAGE_DISPLAY_TIME = 4000; // 4 Sekunden pro Bild
const TRANSITION_TIME = 500; // 0.5 Sekunden für Fade-Effekt
// Image Preloader Hook - lädt nächste 2 Bilder im Hintergrund
const { isPreloaded, preloadProgress } = useImagePreloader(
allGroups,
currentGroupIndex,
currentImageIndex,
getImageSrc,
2 // Preload next 2 images
);
// Gruppen laden
useEffect(() => {
const loadAllGroups = async () => {
try {
setLoading(true);
const groupsData = await fetchAllGroups();
if (groupsData.groups && groupsData.groups.length > 0) {
// Sortiere chronologisch: Jahr (aufsteigend) → Upload-Datum (aufsteigend)
const sortedGroups = [...groupsData.groups].sort((a, b) => {
// Primär: Nach Jahr sortieren (älteste zuerst)
if (a.year !== b.year) {
return a.year - b.year;
}
// Sekundär: Bei gleichem Jahr nach Upload-Datum sortieren
return new Date(a.uploadDate) - new Date(b.uploadDate);
});
setAllGroups(sortedGroups);
setCurrentGroupIndex(0);
setCurrentImageIndex(0);
} else {
setError('Keine Slideshows gefunden');
}
} catch (err) {
console.error('Fehler beim Laden der Gruppen:', err);
setError('Fehler beim Laden der Slideshows');
} finally {
setLoading(false);
}
};
loadAllGroups();
}, []);
// Automatischer Slideshow-Wechsel
const nextImage = useCallback(() => {
if (allGroups.length === 0) return;
const currentGroup = allGroups[currentGroupIndex];
if (!currentGroup || !currentGroup.images) return;
setFadeOut(true);
setTimeout(() => {
if (currentImageIndex + 1 < currentGroup.images.length) {
// Nächstes Bild in der aktuellen Gruppe
setCurrentImageIndex(prev => prev + 1);
} else {
// Zur nächsten Gruppe wechseln (sequenziell, chronologisch sortiert)
const nextGroupIndex = (currentGroupIndex + 1) % allGroups.length;
setCurrentGroupIndex(nextGroupIndex);
setCurrentImageIndex(0);
}
setFadeOut(false);
}, TRANSITION_TIME);
}, [allGroups, currentGroupIndex, currentImageIndex, TRANSITION_TIME]);
// Timer für automatischen Wechsel
useEffect(() => {
if (loading || error || allGroups.length === 0) return;
const timer = setInterval(nextImage, IMAGE_DISPLAY_TIME);
return () => clearInterval(timer);
}, [loading, error, allGroups, nextImage, IMAGE_DISPLAY_TIME]);
// Keyboard-Navigation
useEffect(() => {
const handleKeyPress = (event) => {
switch (event.key) {
case 'Escape':
navigate('/');
break;
case ' ':
case 'ArrowRight':
nextImage();
break;
default:
break;
}
};
document.addEventListener('keydown', handleKeyPress);
return () => document.removeEventListener('keydown', handleKeyPress);
}, [nextImage, navigate]);
// Aktuelle Gruppe und Bild
const currentGroup = allGroups[currentGroupIndex];
const currentImage = currentGroup?.images?.[currentImageIndex];
// Debug: Log Preload-Status (nur in Development)
useEffect(() => {
if (process.env.NODE_ENV === 'development' && currentImage) {
const currentUrl = getImageSrc(currentImage, false);
console.log('[Slideshow Debug]', {
group: `${currentGroupIndex + 1}/${allGroups.length}`,
image: `${currentImageIndex + 1}/${currentGroup?.images?.length || 0}`,
preloaded: isPreloaded(currentUrl),
preloadProgress,
year: currentGroup?.year,
title: currentGroup?.title
});
}
}, [currentGroupIndex, currentImageIndex, currentImage, currentGroup, allGroups.length, isPreloaded, preloadProgress]);
const fullscreenSx = {
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
backgroundColor: '#000',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999,
overflow: 'hidden'
};
const loadingContainerSx = {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'white'
};
if (loading) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<CircularProgress sx={{ color: 'white', mb: 2 }} />
<Typography sx={{ color: 'white' }}>Slideshow wird geladen...</Typography>
</Box>
</Box>
);
}
const homeButtonSx = {
position: 'absolute',
top: '20px',
left: '20px',
color: 'white',
backgroundColor: 'rgba(0,0,0,0.5)',
'&:hover': { backgroundColor: 'rgba(0,0,0,0.8)' }
};
if (error) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>{error}</Typography>
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<HomeIcon />
</IconButton>
</Box>
</Box>
);
}
if (!currentGroup || !currentImage) {
return (
<Box sx={fullscreenSx}>
<Box sx={loadingContainerSx}>
<Typography sx={{ color: 'white', fontSize: '24px' }}>Keine Bilder verfügbar</Typography>
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<HomeIcon />
</IconButton>
</Box>
</Box>
);
}
const exitButtonSx = {
position: 'absolute',
top: '20px',
right: '20px',
color: 'white',
backgroundColor: 'rgba(0,0,0,0.5)',
'&:hover': { backgroundColor: 'rgba(0,0,0,0.8)' }
};
const slideshowImageSx = {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
transition: `opacity ${TRANSITION_TIME}ms ease-in-out`
};
const imageDescriptionSx = {
position: 'fixed',
bottom: '140px',
left: '50%',
transform: 'translateX(-50%)',
backgroundColor: 'rgba(0,0,0,0.7)',
p: '15px 30px',
borderRadius: '8px',
maxWidth: '80%',
textAlign: 'center',
backdropFilter: 'blur(5px)',
zIndex: 10002
};
const imageDescriptionTextSx = {
color: 'white',
fontSize: '18px',
margin: 0,
lineHeight: 1.4,
fontFamily: 'roboto'
};
const descriptionContainerSx = {
position: 'fixed',
left: 40,
bottom: 40,
backgroundColor: 'rgba(0,0,0,0.8)',
p: '25px 35px',
borderRadius: '12px',
maxWidth: '35vw',
minWidth: '260px',
textAlign: 'left',
backdropFilter: 'blur(5px)',
zIndex: 10001,
boxShadow: '0 4px 24px rgba(0,0,0,0.4)'
};
const titleTextSx = { color: 'white', fontSize: '28px', fontWeight: 500, mb: 1, fontFamily: 'roboto' };
const yearAuthorTextSx = { color: '#FFD700', fontSize: '18px', fontWeight: 400, mb: 1, fontFamily: 'roboto' };
const descriptionTextSx = { color: '#E0E0E0', fontSize: '16px', fontWeight: 300, mb: 1, fontFamily: 'roboto', lineHeight: 1.4 };
const metaTextSx = { color: '#999', fontSize: '12px', mt: 1, fontFamily: 'roboto' };
return (
<Box sx={fullscreenSx}>
{/* Navigation Buttons */}
<IconButton sx={homeButtonSx} onClick={() => navigate('/')} title="Zur Startseite">
<HomeIcon />
</IconButton>
<IconButton sx={exitButtonSx} onClick={() => navigate('/')} title="Slideshow beenden">
<ExitIcon />
</IconButton>
{/* Hauptbild */}
<Box component="img" src={getImageSrc(currentImage, false)} alt={currentImage.originalName} sx={{ ...slideshowImageSx, opacity: fadeOut ? 0 : 1 }} />
{/* Bildbeschreibung (wenn vorhanden) */}
{currentImage.imageDescription && (
<Box sx={imageDescriptionSx}>
<Typography sx={imageDescriptionTextSx}>{currentImage.imageDescription}</Typography>
</Box>
)}
{/* Beschreibung */}
<Box sx={descriptionContainerSx}>
{/* Titel */}
<Typography sx={titleTextSx}>{currentGroup.title || 'Unbenanntes Projekt'}</Typography>
{/* Jahr und Name */}
<Typography sx={yearAuthorTextSx}>{currentGroup.year}{currentGroup.name && `${currentGroup.name}`}</Typography>
{/* Beschreibung (wenn vorhanden) */}
{currentGroup.description && <Typography sx={descriptionTextSx}>{currentGroup.description}</Typography>}
{/* Meta-Informationen */}
<Typography sx={metaTextSx}>Bild {currentImageIndex + 1} von {currentGroup.images.length} Slideshow {currentGroupIndex + 1} von {allGroups.length}</Typography>
</Box>
</Box>
);
}
export default SlideshowPage;