Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useCallback, useState } from 'react';
|
||||
import {
|
||||
XMarkIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export interface LightboxItem {
|
||||
id: string;
|
||||
previewUrl: string;
|
||||
downloadUrl: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
items: LightboxItem[];
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
/** Extra per-item action buttons rendered in the top bar (admin use). */
|
||||
renderActions?: (item: LightboxItem, index: number) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-screen photo lightbox with keyboard and swipe navigation, in the
|
||||
* style of the admin gallery preview modal (fixed inset-0 bg-black/90).
|
||||
*/
|
||||
export default function Lightbox({ items, index, onClose, onNavigate, renderActions }: LightboxProps) {
|
||||
const [touchStartX, setTouchStartX] = useState<number | null>(null);
|
||||
const item = items[index];
|
||||
|
||||
const prev = useCallback(() => {
|
||||
onNavigate(index > 0 ? index - 1 : items.length - 1);
|
||||
}, [index, items.length, onNavigate]);
|
||||
const next = useCallback(() => {
|
||||
onNavigate(index < items.length - 1 ? index + 1 : 0);
|
||||
}, [index, items.length, onNavigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') prev();
|
||||
if (e.key === 'ArrowRight') next();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [onClose, prev, next]);
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/90 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
onTouchStart={(e) => setTouchStartX(e.touches[0].clientX)}
|
||||
onTouchEnd={(e) => {
|
||||
if (touchStartX === null) return;
|
||||
const dx = e.changedTouches[0].clientX - touchStartX;
|
||||
if (dx > 60) prev();
|
||||
if (dx < -60) next();
|
||||
setTouchStartX(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className="absolute top-4 right-4 text-white hover:text-gray-300 z-10 p-2 -m-2"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="w-8 h-8" />
|
||||
</button>
|
||||
|
||||
<div
|
||||
className="absolute top-4 left-4 z-10 flex items-center gap-4"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<a
|
||||
href={item.downloadUrl}
|
||||
download={item.filename || true}
|
||||
className="text-white hover:text-gray-300"
|
||||
aria-label="Download"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-7 h-7" />
|
||||
</a>
|
||||
{renderActions?.(item, index)}
|
||||
</div>
|
||||
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
className="absolute left-2 md:left-4 text-white/80 hover:text-white z-10 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
prev();
|
||||
}}
|
||||
aria-label="Previous"
|
||||
>
|
||||
<ChevronLeftIcon className="w-9 h-9" />
|
||||
</button>
|
||||
<button
|
||||
className="absolute right-2 md:right-4 text-white/80 hover:text-white z-10 p-2"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
next();
|
||||
}}
|
||||
aria-label="Next"
|
||||
>
|
||||
<ChevronRightIcon className="w-9 h-9" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={item.previewUrl}
|
||||
alt=""
|
||||
className="max-w-[95vw] max-h-[90vh] object-contain select-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
<div className="absolute bottom-3 inset-x-0 text-center text-white/70 text-sm">
|
||||
{index + 1} / {items.length}
|
||||
</div>
|
||||
|
||||
{/* Preload neighbours so prev/next feels instant. */}
|
||||
<div className="hidden" aria-hidden>
|
||||
{items.length > 1 && (
|
||||
<>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={items[(index + 1) % items.length].previewUrl} alt="" />
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img src={items[(index - 1 + items.length) % items.length].previewUrl} alt="" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user