folx-tv/client/src/components/photo-gallery.tsx
sebastjanartic 4b5b3e5d97 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
2026-03-02 16:39:45 +00:00

375 lines
13 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
interface GalleryImage {
folder: string;
fileName: string;
thumb: string;
large: string;
artist?: string;
}
const PAGE_SIZE = 24;
function thumbUrl(src: string) {
return `/api/gallery/thumb?src=${encodeURIComponent(src)}`;
}
function LazyImage({ src, alt, className, onClick }: { src: string; alt: string; className?: string; onClick?: () => void }) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [loaded, setLoaded] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const obs = new IntersectionObserver(
([entry]) => { if (entry.isIntersecting) { setVisible(true); obs.disconnect(); } },
{ rootMargin: "200px" }
);
obs.observe(el);
return () => obs.disconnect();
}, []);
return (
<div ref={ref} className={className} onClick={onClick}>
{visible ? (
<>
{!loaded && <div className="absolute inset-0 bg-muted animate-pulse" />}
<img
src={src}
alt={alt}
className={`w-full h-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
onLoad={() => setLoaded(true)}
/>
</>
) : (
<div className="w-full h-full bg-muted" />
)}
</div>
);
}
function Lightbox({
images,
startIndex,
onClose,
}: {
images: GalleryImage[];
startIndex: number;
onClose: () => void;
}) {
const [index, setIndex] = useState(startIndex);
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
if (e.key === "ArrowLeft") prev();
if (e.key === "ArrowRight") next();
};
window.addEventListener("keydown", handler);
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"
onClick={onClose}
data-testid="lightbox-overlay"
>
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="absolute top-4 right-4 text-white/70 hover:text-white z-50 p-2"
data-testid="button-lightbox-close"
>
<X className="w-8 h-8" />
</button>
<button
onClick={(e) => { e.stopPropagation(); prev(); }}
className="absolute left-2 md:left-6 text-white/70 hover:text-white z-50 p-2"
data-testid="button-lightbox-prev"
>
<ChevronLeft className="w-10 h-10" />
</button>
<button
onClick={(e) => { e.stopPropagation(); next(); }}
className="absolute right-2 md:right-6 text-white/70 hover:text-white z-50 p-2"
data-testid="button-lightbox-next"
>
<ChevronRight className="w-10 h-10" />
</button>
<div className="w-[85vh] h-[85vh] max-w-[95vw] max-h-[95vw] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
<img
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 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>
);
}
function SingleImageCarousel({
images,
autoPlay = true,
interval = 5000,
onExpand,
}: {
images: GalleryImage[];
autoPlay?: boolean;
interval?: number;
onExpand?: (index: number) => void;
}) {
const [index, setIndex] = useState(0);
const [paused, setPaused] = useState(false);
const prev = useCallback(() => setIndex((i) => (i - 1 + images.length) % images.length), [images.length]);
const next = useCallback(() => setIndex((i) => (i + 1) % images.length), [images.length]);
useEffect(() => {
if (!autoPlay || paused || images.length <= 1) return;
const timer = setInterval(next, interval);
return () => clearInterval(timer);
}, [autoPlay, paused, next, interval, images.length]);
if (images.length === 0) return null;
const current = images[index];
return (
<div
className="relative w-full h-full flex flex-col"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
<div className="relative w-full overflow-hidden rounded-b-lg flex-1 min-h-[200px]">
<img
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/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}
className="absolute left-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
data-testid="button-gallery-prev"
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
onClick={next}
className="absolute right-1.5 top-1/2 -translate-y-1/2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
data-testid="button-gallery-next"
>
<ChevronRight className="w-4 h-4" />
</button>
{onExpand && (
<button
onClick={() => onExpand(index)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 hover:bg-black/70 flex items-center justify-center text-white/80 hover:text-white transition-all"
data-testid="button-gallery-expand"
>
<Maximize2 className="w-3.5 h-3.5" />
</button>
)}
<div className="absolute bottom-2 left-0 right-0 flex justify-center gap-1">
{images.slice(0, Math.min(images.length, 20)).map((_, i) => (
<button
key={i}
onClick={() => setIndex(i)}
className={`w-1.5 h-1.5 rounded-full transition-all ${i === index ? "bg-white w-3" : "bg-white/40 hover:bg-white/60"}`}
data-testid={`button-gallery-dot-${i}`}
/>
))}
{images.length > 20 && (
<span className="text-white/50 text-[9px] ml-1">+{images.length - 20}</span>
)}
</div>
</div>
</div>
);
}
export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: boolean } = {}) {
const { data: rawImages, isLoading } = useQuery<GalleryImage[]>({
queryKey: ["/api/gallery"],
});
const images = reverseOrder && rawImages ? [...rawImages].reverse() : rawImages;
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
if (isLoading) {
return (
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full" data-testid="widget-gallery-loading">
<div className="p-3 flex items-center gap-2 border-b border-card-border">
<Images className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
</div>
<div className="aspect-[4/5] bg-muted animate-pulse" />
</div>
);
}
if (!images || images.length === 0) return null;
return (
<>
<div className="bg-card rounded-lg border border-card-border overflow-hidden h-full flex flex-col" data-testid="widget-gallery">
<div className="p-3 flex items-center gap-2 border-b border-card-border flex-shrink-0">
<Images className="w-4 h-4 text-primary" />
<h3 className="font-bold text-card-foreground text-sm">Fotogalerie</h3>
</div>
<SingleImageCarousel
images={images}
autoPlay={true}
interval={10000}
onExpand={(i) => setLightboxIndex(i)}
/>
</div>
{lightboxIndex !== null && (
<Lightbox
images={images}
startIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</>
);
}
export default function GalleryPage() {
const { data: images, isLoading } = useQuery<GalleryImage[]>({
queryKey: ["/api/gallery"],
});
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">
{isLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="aspect-square bg-card rounded-lg animate-pulse" />
))}
</div>
) : images && images.length > 0 ? (
<>
<p className="text-muted-foreground text-sm mb-4" data-testid="text-gallery-count">
{Math.min(visibleCount, totalCount)} von {totalCount} Fotos
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
{visibleImages.map((img, i) => (
<button
key={`${img.folder}-${img.fileName}`}
onClick={() => setLightboxIndex(i)}
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
data-testid={`button-gallery-image-${i}`}
>
<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>
)}
{lightboxIndex !== null && images && (
<Lightbox
images={images}
startIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}