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:
Michilis
2026-07-25 17:32:23 +00:00
co-authored by Cursor
parent 4772b85f3d
commit c9a600b6d6
61 changed files with 6912 additions and 7 deletions
+145
View File
@@ -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>
);
}