Phones cannot land downloads in the photo library, so the gallery opens a share sheet for a cacheable preview while streaming via a dedicated download endpoint and recording preview sizes. Co-authored-by: Cursor <cursoragent@cursor.com>
247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useCallback, useRef, useState } from 'react';
|
|
import {
|
|
XMarkIcon,
|
|
ChevronLeftIcon,
|
|
ChevronRightIcon,
|
|
ArrowDownTrayIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import Spinner from '@/components/ui/Spinner';
|
|
|
|
export interface LightboxItem {
|
|
id: string;
|
|
previewUrl: string;
|
|
downloadUrl: string;
|
|
filename?: string;
|
|
/** Small thumbnail for the filmstrip; falls back to previewUrl. */
|
|
thumbUrl?: 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;
|
|
/**
|
|
* Handles the download in JS instead of navigating, so the button can show
|
|
* progress. Without it the button stays a plain <a download>.
|
|
*/
|
|
onDownload?: (item: LightboxItem) => void;
|
|
/** Id of the item currently downloading (pairs with onDownload). */
|
|
downloadingId?: string | null;
|
|
downloadLabels?: { download: string; downloading: string };
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
onDownload,
|
|
downloadingId,
|
|
downloadLabels,
|
|
}: LightboxProps) {
|
|
const touchStart = useRef<{ x: number; y: number } | null>(null);
|
|
const activeThumbRef = useRef<HTMLButtonElement | null>(null);
|
|
const item = items[index];
|
|
const hasMultiple = items.length > 1;
|
|
const downloading = !!downloadingId && item?.id === downloadingId;
|
|
|
|
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]);
|
|
|
|
// Keep the active thumbnail centred in the filmstrip as you navigate.
|
|
useEffect(() => {
|
|
activeThumbRef.current?.scrollIntoView({
|
|
behavior: 'smooth',
|
|
inline: 'center',
|
|
block: 'nearest',
|
|
});
|
|
}, [index]);
|
|
|
|
if (!item) return null;
|
|
|
|
return (
|
|
<div
|
|
className="fixed inset-0 bg-black/90 z-50 flex flex-col"
|
|
onClick={onClose}
|
|
onTouchStart={(e) => {
|
|
touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
|
|
}}
|
|
onTouchEnd={(e) => {
|
|
if (touchStart.current === null) return;
|
|
const dx = e.changedTouches[0].clientX - touchStart.current.x;
|
|
const dy = e.changedTouches[0].clientY - touchStart.current.y;
|
|
touchStart.current = null;
|
|
// Only treat as a swipe when it's clearly horizontal.
|
|
if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return;
|
|
if (dx > 0) prev();
|
|
else next();
|
|
}}
|
|
>
|
|
<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()}
|
|
>
|
|
{onDownload ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => onDownload(item)}
|
|
disabled={downloading}
|
|
aria-busy={downloading}
|
|
aria-label={
|
|
downloading
|
|
? downloadLabels?.downloading || 'Downloading…'
|
|
: downloadLabels?.download || 'Download'
|
|
}
|
|
className={`text-white hover:text-gray-300 flex items-center ${
|
|
downloading ? 'cursor-wait' : ''
|
|
}`}
|
|
>
|
|
{downloading ? (
|
|
<Spinner className="w-6 h-6" />
|
|
) : (
|
|
<ArrowDownTrayIcon className="w-7 h-7" />
|
|
)}
|
|
</button>
|
|
) : (
|
|
<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>
|
|
|
|
{/* Main image area */}
|
|
<div className="relative flex-1 min-h-0 flex items-center justify-center">
|
|
{hasMultiple && (
|
|
<>
|
|
<button
|
|
className="absolute left-2 md:left-4 top-1/2 -translate-y-1/2 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 top-1/2 -translate-y-1/2 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 */}
|
|
{/* Nothing is layered over the image (the navigation buttons sit at
|
|
the edges), and neither -webkit-touch-callout nor -webkit-user-
|
|
select is suppressed here, so iOS long-press → "Save to Photos"
|
|
still works as a backup to the save sheet. Don't add select-none. */}
|
|
<img
|
|
src={item.previewUrl}
|
|
alt=""
|
|
className="max-w-[95vw] max-h-full object-contain [-webkit-touch-callout:default]"
|
|
onClick={(e) => e.stopPropagation()}
|
|
draggable={false}
|
|
/>
|
|
</div>
|
|
|
|
{/* Counter + thumbnail filmstrip you can skip through. */}
|
|
<div
|
|
className="shrink-0 pb-3 pt-2"
|
|
onClick={(e) => e.stopPropagation()}
|
|
onTouchStart={(e) => e.stopPropagation()}
|
|
onTouchEnd={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="text-center text-white/70 text-sm mb-2">
|
|
{index + 1} / {items.length}
|
|
</div>
|
|
{hasMultiple && (
|
|
<div className="flex gap-2 overflow-x-auto px-4 pb-1 justify-start sm:justify-center [scrollbar-width:thin]">
|
|
{items.map((it, i) => (
|
|
<button
|
|
key={it.id}
|
|
ref={i === index ? activeThumbRef : null}
|
|
onClick={() => onNavigate(i)}
|
|
aria-label={`Photo ${i + 1}`}
|
|
aria-current={i === index}
|
|
className={`relative shrink-0 overflow-hidden rounded transition ${
|
|
i === index
|
|
? 'ring-2 ring-white opacity-100'
|
|
: 'opacity-50 hover:opacity-90'
|
|
}`}
|
|
>
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={it.thumbUrl || it.previewUrl}
|
|
alt=""
|
|
className="h-14 w-14 sm:h-16 sm:w-16 object-cover"
|
|
draggable={false}
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Preload neighbours so prev/next feels instant. */}
|
|
<div className="hidden" aria-hidden>
|
|
{hasMultiple && (
|
|
<>
|
|
{/* 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>
|
|
);
|
|
}
|