|
|
|
|
@ -1,8 +1,11 @@
|
|
|
|
|
import { X } from "lucide-react";
|
|
|
|
|
import React, { useEffect, useState } from "react";
|
|
|
|
|
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
|
|
|
|
import React, { useEffect, useMemo, useState } from "react";
|
|
|
|
|
import MotionPhotoPreview from "@/components/MotionPhotoPreview";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Dialog, DialogContent } from "@/components/ui/dialog";
|
|
|
|
|
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
|
|
|
|
import { VisuallyHidden } from "@/components/ui/visually-hidden";
|
|
|
|
|
import useMediaQuery from "@/hooks/useMediaQuery";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import type { PreviewMediaItem } from "@/utils/media-item";
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
@ -14,115 +17,216 @@ interface Props {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
|
|
|
|
|
const sm = useMediaQuery("sm");
|
|
|
|
|
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
|
|
|
|
const previewItems =
|
|
|
|
|
items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" }));
|
|
|
|
|
const previewItems = useMemo(
|
|
|
|
|
() => items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" })),
|
|
|
|
|
[imgUrls, items],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Update current index when initialIndex prop changes
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setCurrentIndex(initialIndex);
|
|
|
|
|
}, [initialIndex]);
|
|
|
|
|
if (open) {
|
|
|
|
|
setCurrentIndex(initialIndex);
|
|
|
|
|
}
|
|
|
|
|
}, [initialIndex, open]);
|
|
|
|
|
|
|
|
|
|
const itemCount = previewItems.length;
|
|
|
|
|
const safeIndex = Math.max(0, Math.min(currentIndex, itemCount - 1));
|
|
|
|
|
const currentItem = previewItems[safeIndex];
|
|
|
|
|
const hasMultiple = itemCount > 1;
|
|
|
|
|
const canGoPrevious = safeIndex > 0;
|
|
|
|
|
const canGoNext = safeIndex < itemCount - 1;
|
|
|
|
|
|
|
|
|
|
// Handle keyboard navigation
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
|
|
|
if (!open) return;
|
|
|
|
|
|
|
|
|
|
switch (event.key) {
|
|
|
|
|
case "Escape":
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
break;
|
|
|
|
|
case "ArrowRight":
|
|
|
|
|
setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1));
|
|
|
|
|
break;
|
|
|
|
|
case "ArrowLeft":
|
|
|
|
|
setCurrentIndex((prev) => Math.max(prev - 1, 0));
|
|
|
|
|
break;
|
|
|
|
|
default:
|
|
|
|
|
break;
|
|
|
|
|
if (!open) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === "Escape") {
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === "ArrowLeft") {
|
|
|
|
|
setCurrentIndex((prev) => Math.max(prev - 1, 0));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (event.key === "ArrowRight") {
|
|
|
|
|
setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1));
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
document.addEventListener("keydown", handleKeyDown);
|
|
|
|
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
|
|
|
|
}, [open, onOpenChange]);
|
|
|
|
|
|
|
|
|
|
const handleClose = () => {
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
handleClose();
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
}, [itemCount, onOpenChange, open]);
|
|
|
|
|
|
|
|
|
|
// Return early if no images provided
|
|
|
|
|
if (!previewItems.length) return null;
|
|
|
|
|
if (!itemCount || !currentItem) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ensure currentIndex is within bounds
|
|
|
|
|
const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1));
|
|
|
|
|
const currentItem = previewItems[safeIndex];
|
|
|
|
|
const handleClose = () => onOpenChange(false);
|
|
|
|
|
const handlePrevious = () => setCurrentIndex((prev) => Math.max(prev - 1, 0));
|
|
|
|
|
const handleNext = () => setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1));
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
|
|
|
<DialogContent
|
|
|
|
|
className="!w-[100vw] !h-[100vh] !max-w-[100vw] !max-h-[100vw] p-0 border-0 shadow-none bg-transparent [&>button]:hidden"
|
|
|
|
|
showCloseButton={false}
|
|
|
|
|
className="!h-[100vh] !w-[100vw] !max-h-[100vh] !max-w-[100vw] overflow-hidden border-0 bg-black/92 p-0 shadow-none"
|
|
|
|
|
aria-describedby="image-preview-description"
|
|
|
|
|
>
|
|
|
|
|
{/* Close button */}
|
|
|
|
|
<div className="fixed top-4 right-4 z-50">
|
|
|
|
|
<Button
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="rounded-full bg-popover/20 hover:bg-popover/30 border-border/20 backdrop-blur-sm"
|
|
|
|
|
aria-label="Close image preview"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4 text-popover-foreground" />
|
|
|
|
|
</Button>
|
|
|
|
|
<VisuallyHidden>
|
|
|
|
|
<DialogTitle>{currentItem.filename || "Attachment preview"}</DialogTitle>
|
|
|
|
|
</VisuallyHidden>
|
|
|
|
|
|
|
|
|
|
<div className="absolute inset-x-0 top-0 z-20 bg-linear-to-b from-black/70 via-black/35 to-transparent px-3 pb-6 pt-3 sm:px-5 sm:pt-4">
|
|
|
|
|
<div className="flex items-start justify-between gap-3">
|
|
|
|
|
<div className="min-w-0 text-white">
|
|
|
|
|
<div className="truncate text-sm font-medium">{currentItem.filename || "Attachment"}</div>
|
|
|
|
|
{hasMultiple && (
|
|
|
|
|
<div className="mt-1 text-xs text-white/70">
|
|
|
|
|
{safeIndex + 1} / {itemCount}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={handleClose}
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
className="shrink-0 rounded-full bg-white/10 text-white hover:bg-white/16 hover:text-white"
|
|
|
|
|
aria-label="Close preview"
|
|
|
|
|
>
|
|
|
|
|
<X className="h-4 w-4" />
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Image container */}
|
|
|
|
|
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}>
|
|
|
|
|
{currentItem.kind === "video" ? (
|
|
|
|
|
<video
|
|
|
|
|
key={currentItem.id}
|
|
|
|
|
src={currentItem.sourceUrl}
|
|
|
|
|
poster={currentItem.posterUrl}
|
|
|
|
|
className="max-w-full max-h-full object-contain"
|
|
|
|
|
controls
|
|
|
|
|
autoPlay
|
|
|
|
|
/>
|
|
|
|
|
) : currentItem.kind === "motion" ? (
|
|
|
|
|
<MotionPhotoPreview
|
|
|
|
|
key={currentItem.id}
|
|
|
|
|
posterUrl={currentItem.posterUrl}
|
|
|
|
|
motionUrl={currentItem.motionUrl}
|
|
|
|
|
alt={`Preview live photo ${safeIndex + 1} of ${previewItems.length}`}
|
|
|
|
|
presentationTimestampUs={currentItem.presentationTimestampUs}
|
|
|
|
|
badgeClassName="left-4 top-4"
|
|
|
|
|
mediaClassName="max-h-[calc(100vh-2rem)] max-w-[calc(100vw-2rem)] object-contain sm:max-h-[calc(100vh-4rem)] sm:max-w-[calc(100vw-4rem)]"
|
|
|
|
|
<div
|
|
|
|
|
className="flex h-full w-full items-center justify-center px-3 pb-20 pt-16 sm:px-16 sm:pb-8 sm:pt-20"
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
if (event.target === event.currentTarget) {
|
|
|
|
|
handleClose();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex max-h-full max-w-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
|
|
|
|
|
{currentItem.kind === "video" ? (
|
|
|
|
|
<video
|
|
|
|
|
key={currentItem.id}
|
|
|
|
|
src={currentItem.sourceUrl}
|
|
|
|
|
poster={currentItem.posterUrl}
|
|
|
|
|
className="max-h-[calc(100vh-8rem)] max-w-[calc(100vw-1.5rem)] rounded-md object-contain sm:max-h-[calc(100vh-7rem)] sm:max-w-[calc(100vw-8rem)]"
|
|
|
|
|
controls
|
|
|
|
|
autoPlay
|
|
|
|
|
playsInline
|
|
|
|
|
/>
|
|
|
|
|
) : currentItem.kind === "motion" ? (
|
|
|
|
|
<MotionPhotoPreview
|
|
|
|
|
key={currentItem.id}
|
|
|
|
|
posterUrl={currentItem.posterUrl}
|
|
|
|
|
motionUrl={currentItem.motionUrl}
|
|
|
|
|
alt={`Preview live photo ${safeIndex + 1} of ${itemCount}`}
|
|
|
|
|
presentationTimestampUs={currentItem.presentationTimestampUs}
|
|
|
|
|
badgeClassName="left-3 top-3 sm:left-4 sm:top-4"
|
|
|
|
|
mediaClassName="max-h-[calc(100vh-8rem)] max-w-[calc(100vw-1.5rem)] rounded-md object-contain sm:max-h-[calc(100vh-7rem)] sm:max-w-[calc(100vw-8rem)]"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<img
|
|
|
|
|
src={currentItem.sourceUrl}
|
|
|
|
|
alt={`Preview image ${safeIndex + 1} of ${itemCount}`}
|
|
|
|
|
className="max-h-[calc(100vh-8rem)] max-w-[calc(100vw-1.5rem)] rounded-md object-contain select-none sm:max-h-[calc(100vh-7rem)] sm:max-w-[calc(100vw-8rem)]"
|
|
|
|
|
draggable={false}
|
|
|
|
|
loading="eager"
|
|
|
|
|
decoding="async"
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{hasMultiple && sm && (
|
|
|
|
|
<>
|
|
|
|
|
<NavButton
|
|
|
|
|
side="left"
|
|
|
|
|
disabled={!canGoPrevious}
|
|
|
|
|
label="Previous item"
|
|
|
|
|
onClick={handlePrevious}
|
|
|
|
|
icon={<ChevronLeft className="h-5 w-5" />}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<img
|
|
|
|
|
src={currentItem.sourceUrl}
|
|
|
|
|
alt={`Preview image ${safeIndex + 1} of ${previewItems.length}`}
|
|
|
|
|
className="max-w-full max-h-full object-contain select-none"
|
|
|
|
|
draggable={false}
|
|
|
|
|
loading="eager"
|
|
|
|
|
decoding="async"
|
|
|
|
|
<NavButton
|
|
|
|
|
side="right"
|
|
|
|
|
disabled={!canGoNext}
|
|
|
|
|
label="Next item"
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
icon={<ChevronRight className="h-5 w-5" />}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{hasMultiple && !sm && (
|
|
|
|
|
<div className="absolute inset-x-0 bottom-0 z-20 px-3 pb-3 pt-6">
|
|
|
|
|
<div className="mx-auto flex max-w-xs items-center justify-between rounded-full bg-black/55 px-2 py-2 backdrop-blur-sm">
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handlePrevious}
|
|
|
|
|
disabled={!canGoPrevious}
|
|
|
|
|
className="rounded-full px-3 text-white hover:bg-white/10 hover:text-white disabled:text-white/35"
|
|
|
|
|
>
|
|
|
|
|
Prev
|
|
|
|
|
</Button>
|
|
|
|
|
<div className="px-3 text-xs text-white/75">
|
|
|
|
|
{safeIndex + 1} / {itemCount}
|
|
|
|
|
</div>
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={handleNext}
|
|
|
|
|
disabled={!canGoNext}
|
|
|
|
|
className="rounded-full px-3 text-white hover:bg-white/10 hover:text-white disabled:text-white/35"
|
|
|
|
|
>
|
|
|
|
|
Next
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Screen reader description */}
|
|
|
|
|
<div id="image-preview-description" className="sr-only">
|
|
|
|
|
Attachment preview dialog. Press Escape to close or click outside the media.
|
|
|
|
|
Attachment preview dialog. Press Escape to close and use left or right arrow keys to switch items.
|
|
|
|
|
</div>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NavButtonProps {
|
|
|
|
|
side: "left" | "right";
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
label: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
icon: React.ReactNode;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const NavButton = ({ side, disabled, label, onClick, icon }: NavButtonProps) => (
|
|
|
|
|
<Button
|
|
|
|
|
type="button"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
size="icon"
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
aria-label={label}
|
|
|
|
|
className={cn(
|
|
|
|
|
"absolute top-1/2 z-20 hidden h-11 w-11 -translate-y-1/2 rounded-full bg-white/10 text-white backdrop-blur-sm hover:bg-white/16 hover:text-white disabled:opacity-25 sm:flex",
|
|
|
|
|
side === "left" ? "left-4" : "right-4",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{icon}
|
|
|
|
|
</Button>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
export default PreviewImageDialog;
|
|
|
|
|
|