Improve photo gallery with artist names and infinite scrolling
Refactor photo gallery to include artist names extracted from filenames, implement pagination with infinite scrolling, and optimize image loading. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 79e0a082-1752-4a36-8483-997b7269c4f3 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/nFw7xof Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -7,8 +7,11 @@ interface GalleryImage {
|
||||
fileName: string;
|
||||
thumb: string;
|
||||
large: string;
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
function thumbUrl(src: string) {
|
||||
return `/api/gallery/thumb?src=${encodeURIComponent(src)}`;
|
||||
}
|
||||
@@ -72,6 +75,8 @@ function Lightbox({
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
const current = images[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
||||
@@ -104,15 +109,18 @@ function Lightbox({
|
||||
|
||||
<div className="w-[85vh] h-[85vh] max-w-[95vw] max-h-[95vw] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
||||
<img
|
||||
src={images[index].large}
|
||||
alt={images[index].fileName}
|
||||
src={current.large}
|
||||
alt={current.artist || current.fileName}
|
||||
className="w-full h-full object-cover rounded-lg"
|
||||
data-testid="img-lightbox"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-4 text-white/60 text-sm" data-testid="text-lightbox-counter">
|
||||
{index + 1} / {images.length}
|
||||
<div className="absolute bottom-4 flex flex-col items-center gap-1" data-testid="text-lightbox-counter">
|
||||
{current.artist && (
|
||||
<span className="text-white text-sm font-medium" data-testid="text-lightbox-artist">{current.artist}</span>
|
||||
)}
|
||||
<span className="text-white/60 text-sm">{index + 1} / {images.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -143,6 +151,8 @@ function SingleImageCarousel({
|
||||
|
||||
if (images.length === 0) return null;
|
||||
|
||||
const current = images[index];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative w-full h-full flex flex-col"
|
||||
@@ -151,15 +161,23 @@ function SingleImageCarousel({
|
||||
>
|
||||
<div className="relative w-full overflow-hidden rounded-b-lg flex-1 min-h-[200px]">
|
||||
<img
|
||||
src={images[index].large || images[index].thumb}
|
||||
alt={images[index].fileName}
|
||||
src={current.large || current.thumb}
|
||||
alt={current.artist || current.fileName}
|
||||
className="absolute inset-0 w-full h-full object-cover transition-opacity duration-500"
|
||||
style={{ objectPosition: "center 35%" }}
|
||||
loading="lazy"
|
||||
data-testid="img-gallery-current"
|
||||
/>
|
||||
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/40 via-transparent to-transparent" />
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent" />
|
||||
|
||||
{current.artist && (
|
||||
<div className="absolute bottom-8 left-0 right-0 text-center px-2">
|
||||
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid="text-gallery-artist">
|
||||
{current.artist}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={prev}
|
||||
@@ -259,6 +277,27 @@ export default function GalleryPage() {
|
||||
});
|
||||
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const totalCount = images?.length || 0;
|
||||
const visibleImages = images?.slice(0, visibleCount) || [];
|
||||
const hasMore = visibleCount < totalCount;
|
||||
|
||||
useEffect(() => {
|
||||
const el = sentinelRef.current;
|
||||
if (!el || !hasMore) return;
|
||||
const obs = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisibleCount((c) => Math.min(c + PAGE_SIZE, totalCount));
|
||||
}
|
||||
},
|
||||
{ rootMargin: "400px" }
|
||||
);
|
||||
obs.observe(el);
|
||||
return () => obs.disconnect();
|
||||
}, [hasMore, totalCount]);
|
||||
|
||||
return (
|
||||
<div data-testid="page-gallery">
|
||||
@@ -271,28 +310,53 @@ export default function GalleryPage() {
|
||||
) : images && images.length > 0 ? (
|
||||
<>
|
||||
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
|
||||
{images.length} Fotos
|
||||
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{images.map((img, i) => (
|
||||
{visibleImages.map((img, i) => (
|
||||
<button
|
||||
key={`${img.folder}-${img.fileName}`}
|
||||
onClick={() => setLightboxIndex(i)}
|
||||
className="group relative aspect-square rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer"
|
||||
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
|
||||
data-testid={`button-gallery-image-${i}`}
|
||||
>
|
||||
<LazyImage
|
||||
src={thumbUrl(img.thumb)}
|
||||
alt={img.fileName}
|
||||
className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Maximize2 className="w-4 h-4 text-white ml-auto" />
|
||||
<div className="relative aspect-square w-full">
|
||||
<LazyImage
|
||||
src={thumbUrl(img.thumb)}
|
||||
alt={img.artist || img.fileName}
|
||||
className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
|
||||
{img.artist && (
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 hidden md:block opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<span className="text-white text-xs font-medium drop-shadow-lg" data-testid={`text-artist-desktop-${i}`}>
|
||||
{img.artist}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 right-0 p-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Maximize2 className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
{img.artist && (
|
||||
<div className="md:hidden px-2 py-1.5 bg-card border-t border-card-border">
|
||||
<span className="text-[11px] text-card-foreground font-medium line-clamp-1" data-testid={`text-artist-mobile-${i}`}>
|
||||
{img.artist}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div ref={sentinelRef} className="flex justify-center py-8" data-testid="gallery-load-more">
|
||||
<div className="flex items-center gap-2 text-muted-foreground text-sm">
|
||||
<div className="w-4 h-4 border-2 border-primary border-t-transparent rounded-full animate-spin" />
|
||||
Weitere Fotos werden geladen...
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="text-center text-muted-foreground">Keine Fotos vorhanden</p>
|
||||
|
||||
Reference in New Issue
Block a user