folx-tv/client/src/components/photo-gallery.tsx
sebastjanartic b49b7512e4 Add a second photo gallery with a reversed image order
Modify the PhotoGalleryWidget to accept a `reverseOrder` prop, and add a second instance of the widget to the home page with this prop enabled.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 517dfa7b-26ac-463d-a6e1-a58c6df97188
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: b84dab4e-11bc-44f5-a773-3e57620ac431
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/517dfa7b-26ac-463d-a6e1-a58c6df97188/0ZGabQy
Replit-Helium-Checkpoint-Created: true
2026-02-28 22:11:20 +00:00

257 lines
8.5 KiB
TypeScript

import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
interface GalleryImage {
folder: string;
fileName: string;
thumb: string;
large: string;
}
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]);
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={images[index].large}
alt={images[index].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>
</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;
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={images[index].large || images[index].thumb}
alt={images[index].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" />
<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);
return (
<div data-testid="page-gallery">
{isLoading ? (
<div className="max-w-3xl mx-auto">
<div className="aspect-[4/3] bg-card rounded-lg animate-pulse" />
</div>
) : images && images.length > 0 ? (
<div className="max-w-3xl mx-auto">
<div className="bg-card rounded-lg border border-card-border overflow-hidden">
<SingleImageCarousel
images={images}
autoPlay={false}
onExpand={(i) => setLightboxIndex(i)}
/>
</div>
<p className="text-center text-muted-foreground text-sm mt-3" data-testid="text-gallery-count">
{images.length} Fotos
</p>
</div>
) : (
<p className="text-center text-muted-foreground">Keine Fotos vorhanden</p>
)}
{lightboxIndex !== null && images && (
<Lightbox
images={images}
startIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
/>
)}
</div>
);
}