200 lines
6.8 KiB
TypeScript
200 lines
6.8 KiB
TypeScript
'use client';
|
|
|
|
import { AnimatePresence, motion } from 'framer-motion';
|
|
import { useCallback, useEffect, useRef } from 'react';
|
|
|
|
import { downloadMedia } from '../lib/download';
|
|
import { editFilterCss, editTransformCss } from '../lib/edits';
|
|
import { formatDate, formatTime } from '../lib/format';
|
|
import { Icon } from '../icons';
|
|
import { useFocusTrap } from '../hooks/useFocusTrap';
|
|
import { useViewMedia } from '../hooks/useViewMedia';
|
|
import { useGallery, useGalleryStoreApi } from '../store/context';
|
|
import { Annotations } from './editor/Annotations';
|
|
import { openShareModal } from './modals';
|
|
import { VideoPlayer } from './VideoPlayer';
|
|
|
|
export function Lightbox() {
|
|
const api = useGalleryStoreApi();
|
|
const lightboxId = useGallery((s) => s.lightboxId);
|
|
const media = useGallery((s) => s.media);
|
|
const features = useGallery((s) => s.config.features);
|
|
const ordered = useViewMedia();
|
|
|
|
const item = media.find((m) => m.id === lightboxId) ?? null;
|
|
const index = ordered.findIndex((m) => m.id === lightboxId);
|
|
const dialogRef = useRef<HTMLDivElement>(null);
|
|
// Escape is handled by the window listener below; trap only manages Tab + focus.
|
|
useFocusTrap(dialogRef, Boolean(item));
|
|
|
|
const go = useCallback(
|
|
(dir: -1 | 1) => {
|
|
if (index === -1) return;
|
|
const next = ordered[index + dir];
|
|
if (next) api.getState().openLightbox(next.id);
|
|
},
|
|
[api, index, ordered],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!lightboxId) return;
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') api.getState().closeLightbox();
|
|
else if (e.key === 'ArrowLeft') go(-1);
|
|
else if (e.key === 'ArrowRight') go(1);
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [lightboxId, api, go]);
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
{item ? (
|
|
<motion.div
|
|
ref={dialogRef}
|
|
className="apg-lightbox"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={`Photo: ${item.name}`}
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
transition={{ duration: 0.18 }}
|
|
>
|
|
<div className="apg-lightbox__bar">
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Close"
|
|
onClick={() => api.getState().closeLightbox()}
|
|
>
|
|
<Icon name="chevron-left" />
|
|
</button>
|
|
<div>
|
|
<div className="apg-lightbox__title">{item.name}</div>
|
|
<div className="apg-lightbox__sub">
|
|
{formatDate(item.takenAt)} · {formatTime(item.takenAt)}
|
|
{item.location?.place ? ` · ${item.location.place}` : ''}
|
|
</div>
|
|
</div>
|
|
<div style={{ flex: 1 }} />
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Info"
|
|
onClick={() => api.getState().toggleInfo()}
|
|
>
|
|
<Icon name="info" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label={item.favorite ? 'Unfavourite' : 'Favourite'}
|
|
aria-pressed={item.favorite}
|
|
style={item.favorite ? { color: '#ff3b30' } : undefined}
|
|
onClick={() => api.getState().toggleFavorite([item.id])}
|
|
>
|
|
<Icon name={item.favorite ? 'heart-fill' : 'heart'} />
|
|
</button>
|
|
{features.editor ? (
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Edit"
|
|
onClick={() => api.getState().openEditor(item.id)}
|
|
>
|
|
<Icon name="adjust" />
|
|
</button>
|
|
) : null}
|
|
{features.sharing ? (
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Share"
|
|
onClick={() => openShareModal([item.id])}
|
|
>
|
|
<Icon name="share" />
|
|
</button>
|
|
) : null}
|
|
{features.export ? (
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Download"
|
|
onClick={() => downloadMedia(item)}
|
|
>
|
|
<Icon name="download" />
|
|
</button>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
className="apg-iconbtn"
|
|
aria-label="Delete"
|
|
onClick={() => {
|
|
api.getState().trash([item.id]);
|
|
api.getState().closeLightbox();
|
|
}}
|
|
>
|
|
<Icon name="trash" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="apg-lightbox__stage">
|
|
{index > 0 ? (
|
|
<button
|
|
type="button"
|
|
className="apg-lightbox__nav apg-lightbox__nav--prev"
|
|
aria-label="Previous"
|
|
onClick={() => go(-1)}
|
|
>
|
|
<Icon name="chevron-left" />
|
|
</button>
|
|
) : null}
|
|
|
|
<motion.div
|
|
key={item.id}
|
|
initial={{ opacity: 0.4, scale: 0.98 }}
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
transition={{ duration: 0.16 }}
|
|
style={{ maxWidth: '100%', maxHeight: '100%', display: 'flex' }}
|
|
>
|
|
{item.kind === 'video' ? (
|
|
<VideoPlayer src={item.src} poster={item.poster} filter={editFilterCss(item.edits)} />
|
|
) : (
|
|
<div style={{ position: 'relative', display: 'inline-block', maxWidth: '100%', maxHeight: '100%' }}>
|
|
<img
|
|
src={item.src}
|
|
alt={item.name}
|
|
style={{
|
|
display: 'block',
|
|
maxWidth: '100%',
|
|
maxHeight: '100%',
|
|
objectFit: 'contain',
|
|
filter: editFilterCss(item.edits),
|
|
transform: editTransformCss(item.edits),
|
|
}}
|
|
/>
|
|
{item.edits?.annotations?.length ? (
|
|
<Annotations annotations={item.edits.annotations} editable={false} />
|
|
) : null}
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
|
|
{index < ordered.length - 1 && index !== -1 ? (
|
|
<button
|
|
type="button"
|
|
className="apg-lightbox__nav apg-lightbox__nav--next"
|
|
aria-label="Next"
|
|
onClick={() => go(1)}
|
|
>
|
|
<Icon name="chevron-right" />
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</motion.div>
|
|
) : null}
|
|
</AnimatePresence>
|
|
);
|
|
}
|