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:
sebastjanartic
2026-03-02 16:39:45 +00:00
parent 553d20d8cb
commit 4b5b3e5d97
8 changed files with 892 additions and 639 deletions
+82 -18
View File
@@ -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>