216 lines
7.2 KiB
TypeScript
216 lines
7.2 KiB
TypeScript
// LightboxModal.tsx
|
|
import React, { useEffect, useRef, useState } from 'react';
|
|
import {
|
|
Dialog,
|
|
IconButton,
|
|
Box,
|
|
Typography,
|
|
useTheme,
|
|
} from '@mui/material';
|
|
import CloseIcon from '@mui/icons-material/Close';
|
|
import ArrowBackIosNewIcon from '@mui/icons-material/ArrowBackIosNew';
|
|
import ArrowForwardIosIcon from '@mui/icons-material/ArrowForwardIos';
|
|
import DownloadIcon from '@mui/icons-material/Download';
|
|
|
|
export type LightboxPhoto = {
|
|
id: number | string;
|
|
url: string;
|
|
title?: string;
|
|
filename?: string;
|
|
created_at?: string;
|
|
};
|
|
|
|
type Props = {
|
|
open: boolean;
|
|
photos: LightboxPhoto[];
|
|
startIndex?: number;
|
|
onClose: () => void;
|
|
startIndexCallback?: (idx: number) => void; // optional: inform parent when slide changes
|
|
};
|
|
|
|
const SWIPE_THRESHOLD = 40; // pixels
|
|
|
|
export default function LightboxModal({ open, photos, startIndex = 0, onClose, startIndexCallback }: Props) {
|
|
const [index, setIndex] = useState<number>(startIndex);
|
|
const theme = useTheme();
|
|
const touchStartX = useRef<number | null>(null);
|
|
const touchDeltaX = useRef(0);
|
|
|
|
useEffect(() => {
|
|
setIndex(startIndex);
|
|
}, [startIndex, open]);
|
|
|
|
useEffect(() => {
|
|
function onKey(e: KeyboardEvent) {
|
|
if (!open) return;
|
|
if (e.key === 'Escape') onClose();
|
|
if (e.key === 'ArrowLeft') prev();
|
|
if (e.key === 'ArrowRight') next();
|
|
}
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [open, index]);
|
|
|
|
useEffect(() => {
|
|
// inform parent if needed
|
|
startIndexCallback?.(index);
|
|
}, [index]);
|
|
|
|
const prev = () => setIndex((i) => (i - 1 + photos.length) % photos.length);
|
|
const next = () => setIndex((i) => (i + 1) % photos.length);
|
|
|
|
// Preload neighbors
|
|
useEffect(() => {
|
|
if (!photos || photos.length === 0) return;
|
|
const prevIdx = (index - 1 + photos.length) % photos.length;
|
|
const nextIdx = (index + 1) % photos.length;
|
|
const p1 = new Image();
|
|
p1.src = photos[prevIdx]?.url;
|
|
const p2 = new Image();
|
|
p2.src = photos[nextIdx]?.url;
|
|
}, [index, photos]);
|
|
|
|
// Touch handlers for swipe
|
|
const onTouchStart = (e: React.TouchEvent) => {
|
|
touchDeltaX.current = 0;
|
|
touchStartX.current = e.touches?.[0]?.clientX ?? null;
|
|
};
|
|
const onTouchMove = (e: React.TouchEvent) => {
|
|
if (touchStartX.current == null) return;
|
|
const x = e.touches?.[0]?.clientX ?? 0;
|
|
touchDeltaX.current = x - (touchStartX.current ?? 0);
|
|
};
|
|
const onTouchEnd = () => {
|
|
const delta = touchDeltaX.current;
|
|
if (Math.abs(delta) > SWIPE_THRESHOLD) {
|
|
if (delta < 0) next();
|
|
else prev();
|
|
}
|
|
touchStartX.current = null;
|
|
touchDeltaX.current = 0;
|
|
};
|
|
|
|
if (!photos || photos.length === 0) return null;
|
|
|
|
const photo = photos[index];
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onClose={onClose}
|
|
fullScreen
|
|
PaperProps={{ sx: { backgroundColor: 'rgba(0,0,0,0.92)' } }}
|
|
>
|
|
<Box
|
|
sx={{
|
|
position: 'relative',
|
|
width: '100%',
|
|
height: '100%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
userSelect: 'none',
|
|
touchAction: 'pan-y',
|
|
}}
|
|
onTouchStart={onTouchStart}
|
|
onTouchMove={onTouchMove}
|
|
onTouchEnd={onTouchEnd}
|
|
>
|
|
{/* Close */}
|
|
<IconButton
|
|
onClick={onClose}
|
|
sx={{ position: 'absolute', top: 16, right: 16, color: 'white', zIndex: 40 }}
|
|
aria-label="Close"
|
|
>
|
|
<CloseIcon />
|
|
</IconButton>
|
|
|
|
{/* Download */}
|
|
<IconButton
|
|
sx={{ position: 'absolute', top: 16, right: 72, color: 'white', zIndex: 40 }}
|
|
aria-label="Download"
|
|
onClick={() => {
|
|
try {
|
|
const a = document.createElement('a');
|
|
a.href = photo.url;
|
|
a.download = `${photo.filename ?? photo.title ?? 'image'}`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
} catch (err) {
|
|
console.error('download', err);
|
|
}
|
|
}}
|
|
>
|
|
<DownloadIcon />
|
|
</IconButton>
|
|
|
|
{/* Prev */}
|
|
<IconButton
|
|
onClick={prev}
|
|
sx={{
|
|
position: 'absolute',
|
|
left: 8,
|
|
color: 'rgba(255,255,255,0.9)',
|
|
zIndex: 40,
|
|
bgcolor: 'transparent',
|
|
}}
|
|
aria-label="Previous"
|
|
>
|
|
<ArrowBackIosNewIcon />
|
|
</IconButton>
|
|
|
|
{/* Next */}
|
|
<IconButton
|
|
onClick={next}
|
|
sx={{
|
|
position: 'absolute',
|
|
right: 8,
|
|
color: 'rgba(255,255,255,0.9)',
|
|
zIndex: 40,
|
|
bgcolor: 'transparent',
|
|
}}
|
|
aria-label="Next"
|
|
>
|
|
<ArrowForwardIosIcon />
|
|
</IconButton>
|
|
|
|
{/* Image */}
|
|
<Box
|
|
sx={{
|
|
maxWidth: '95%',
|
|
maxHeight: '88%',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
<img
|
|
src={photo.url}
|
|
alt={photo.title ?? photo.filename}
|
|
style={{
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
borderRadius: 6,
|
|
boxShadow: '0 10px 30px rgba(0,0,0,0.6)',
|
|
}}
|
|
draggable={false}
|
|
/>
|
|
</Box>
|
|
|
|
{/* Caption / Position */}
|
|
<Box sx={{ position: 'absolute', bottom: 20, left: 20, right: 20, textAlign: 'center', zIndex: 40 }}>
|
|
<Typography variant="subtitle1" color="white" noWrap>
|
|
{photo.title ?? photo.filename}
|
|
</Typography>
|
|
<Typography variant="caption" color="rgba(255,255,255,0.8)">
|
|
{index + 1} / {photos.length} {photo.created_at ? `• ${new Date(photo.created_at).toLocaleDateString()}` : ''}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
</Dialog>
|
|
);
|
|
}
|