Add an admin page for managing gallery images and artist names

Introduce a new admin route at `/admin/gallery` enabling users to set focal points and artist names for gallery images, with corresponding API endpoints and data management.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 23852c00-4779-460a-9e0c-d09fee4b6c92
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Event-Id: 70b6b9df-7da9-46f3-afd7-dbcd9aac1855
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/f209e72a-0939-48fa-84fc-57854de71967/23852c00-4779-460a-9e0c-d09fee4b6c92/ncMMRQ9
Replit-Helium-Checkpoint-Created: true
This commit is contained in:
sebastjanartic
2026-03-06 10:48:43 +00:00
parent 8a9004d640
commit 9d8d29208a
7 changed files with 514 additions and 25 deletions
+25 -22
View File
@@ -2,34 +2,24 @@ import { useQuery } from "@tanstack/react-query";
import { useState, useEffect, useCallback, useRef } from "react";
import { ChevronLeft, ChevronRight, X, Images, Maximize2 } from "lucide-react";
interface GalleryImage {
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;
function useIsMobile() {
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
useEffect(() => {
const handler = () => setIsMobile(window.innerWidth < 768);
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}, []);
return isMobile;
}
function cloudinaryTransform(src: string, transform: string) {
if (!src.includes("res.cloudinary.com")) return src;
return src.replace(/\/upload\/[^/]+\//, `/upload/${transform}/`);
}
function thumbUrl(src: string) {
return src;
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 }) {
@@ -72,10 +62,12 @@ function Lightbox({
images,
startIndex,
onClose,
focalPoints,
}: {
images: GalleryImage[];
startIndex: number;
onClose: () => void;
focalPoints: FocalPointMap;
}) {
const [index, setIndex] = useState(startIndex);
@@ -148,11 +140,13 @@ function SingleImageCarousel({
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);
@@ -181,7 +175,7 @@ function SingleImageCarousel({
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%" }}
style={{ objectPosition: getObjectPosition(current.fileName, focalPoints, "center 35%") }}
loading="lazy"
data-testid="img-gallery-current"
/>
@@ -245,6 +239,9 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
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);
@@ -275,6 +272,7 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
autoPlay={true}
interval={10000}
onExpand={(i) => setLightboxIndex(i)}
focalPoints={focalPoints || {}}
/>
</div>
@@ -283,6 +281,7 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
images={images}
startIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
focalPoints={focalPoints || {}}
/>
)}
</>
@@ -290,15 +289,18 @@ export function PhotoGalleryWidget({ reverseOrder = false }: { reverseOrder?: bo
}
export default function GalleryPage() {
const isMobile = useIsMobile();
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;
@@ -341,10 +343,10 @@ export default function GalleryPage() {
>
<div className="relative w-full aspect-[16/9]">
<LazyImage
src={img.mobile || thumbUrl(img.thumb)}
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="center 15%"
objectPosition={getObjectPosition(img.fileName, fp)}
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors" />
{img.artist && (
@@ -387,6 +389,7 @@ export default function GalleryPage() {
images={images}
startIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
focalPoints={fp}
/>
)}
</div>