421 lines
15 KiB
TypeScript
421 lines
15 KiB
TypeScript
import { useQuery } from "@tanstack/react-query";
|
|
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
|
|
import { InArticleAd } from "./adsense";
|
|
|
|
export interface GalleryImage {
|
|
folder: string;
|
|
fileName: string;
|
|
thumb: string;
|
|
large: string;
|
|
mobile?: string;
|
|
full?: string;
|
|
artist?: string;
|
|
}
|
|
|
|
export type FocalPointMap = Record<string, { x: number; y: number }>;
|
|
|
|
const PAGE_SIZE = 24;
|
|
|
|
export function getObjectPosition(fileName: string, focalPoints: FocalPointMap, fallback = "center 15%"): string {
|
|
const fp = focalPoints[fileName];
|
|
if (fp) return `${fp.x}% ${fp.y}%`;
|
|
return fallback;
|
|
}
|
|
|
|
function LazyImage({ src, alt, className, onClick, objectPosition }: { src: string; alt: string; className?: string; onClick?: () => void; objectPosition?: string }) {
|
|
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"}`}
|
|
style={objectPosition ? { objectPosition } : undefined}
|
|
onLoad={() => setLoaded(true)}
|
|
/>
|
|
</>
|
|
) : (
|
|
<div className="w-full h-full bg-muted" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Lightbox({
|
|
images,
|
|
startIndex,
|
|
onClose,
|
|
focalPoints,
|
|
}: {
|
|
images: GalleryImage[];
|
|
startIndex: number;
|
|
onClose: () => void;
|
|
focalPoints: FocalPointMap;
|
|
}) {
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
const orig = document.body.style.overflow;
|
|
document.body.style.overflow = "hidden";
|
|
return () => { document.body.style.overflow = orig; };
|
|
}, []);
|
|
|
|
const current = images[index];
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
|
|
onClick={onClose}
|
|
onTouchMove={(e) => e.preventDefault()}
|
|
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="max-w-[90vw] max-h-[85vh] flex items-center justify-center" onClick={(e) => e.stopPropagation()}>
|
|
<img
|
|
src={current.large}
|
|
alt={current.artist || current.fileName}
|
|
className="max-w-full max-h-[85vh] object-contain 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,
|
|
focalPoints,
|
|
}: {
|
|
images: GalleryImage[];
|
|
autoPlay?: boolean;
|
|
interval?: number;
|
|
onExpand?: (index: number) => void;
|
|
focalPoints: FocalPointMap;
|
|
}) {
|
|
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: getObjectPosition(current.fileName, focalPoints, "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", "widget"],
|
|
queryFn: () => fetch("/api/gallery?limit=30").then((r) => r.json()),
|
|
});
|
|
const { data: focalPoints } = useQuery<FocalPointMap>({
|
|
queryKey: ["/api/gallery/focal-points"],
|
|
});
|
|
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 min-h-[320px]" 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="flex-1 bg-muted animate-pulse min-h-[280px]" />
|
|
</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 min-h-[320px]" 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)}
|
|
focalPoints={focalPoints || {}}
|
|
/>
|
|
</div>
|
|
|
|
{lightboxIndex !== null && (
|
|
<Lightbox
|
|
images={images}
|
|
startIndex={lightboxIndex}
|
|
onClose={() => setLightboxIndex(null)}
|
|
focalPoints={focalPoints || {}}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function GalleryPage() {
|
|
const { data: images, isLoading } = useQuery<GalleryImage[]>({
|
|
queryKey: ["/api/gallery"],
|
|
});
|
|
const { data: focalPoints } = useQuery<FocalPointMap>({
|
|
queryKey: ["/api/gallery/focal-points"],
|
|
});
|
|
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
|
const sentinelRef = useRef<HTMLDivElement>(null);
|
|
|
|
const fp = focalPoints || {};
|
|
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>
|
|
{(() => {
|
|
const AD_EVERY = 12;
|
|
const chunks: GalleryImage[][] = [];
|
|
for (let i = 0; i < visibleImages.length; i += AD_EVERY) {
|
|
chunks.push(visibleImages.slice(i, i + AD_EVERY));
|
|
}
|
|
return chunks.map((chunk, ci) => (
|
|
<div key={ci}>
|
|
{ci > 0 && <InArticleAd />}
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
|
{chunk.map((img, j) => {
|
|
const globalIdx = ci * AD_EVERY + j;
|
|
return (
|
|
<button
|
|
key={`${img.folder}-${img.fileName}`}
|
|
onClick={() => setLightboxIndex(globalIdx)}
|
|
className="group relative rounded-lg overflow-hidden bg-card border border-card-border cursor-pointer flex flex-col"
|
|
data-testid={`button-gallery-image-${globalIdx}`}
|
|
>
|
|
<div className="relative w-full aspect-[16/9]">
|
|
<LazyImage
|
|
src={img.mobile || img.thumb}
|
|
alt={img.artist || img.fileName}
|
|
className="absolute inset-0 w-full h-full transition-transform duration-500 group-hover:scale-110"
|
|
objectPosition={getObjectPosition(img.fileName, fp)}
|
|
/>
|
|
<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-${globalIdx}`}>
|
|
{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-${globalIdx}`}>
|
|
{img.artist}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</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)}
|
|
focalPoints={fp}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|