Commit aafcc21a authored by boojack's avatar boojack

fix: improve image preview dialog and live photo trigger

parent 6b0487dc
...@@ -41,10 +41,11 @@ const MotionPhotoPreview = ({ ...@@ -41,10 +41,11 @@ const MotionPhotoPreview = ({
containerClassName={cn("max-w-full max-h-full", containerClassName)} containerClassName={cn("max-w-full max-h-full", containerClassName)}
mediaClassName={mediaClassName} mediaClassName={mediaClassName}
/> />
<button <div
type="button" role="button"
tabIndex={0}
className={cn( className={cn(
"absolute rounded-full border border-border/45 bg-background/65 px-2.5 py-1 text-xs font-semibold tracking-wide text-foreground backdrop-blur-sm transition-colors hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50", "absolute select-none rounded-full border border-border/45 bg-background/65 px-2.5 py-1 text-xs font-semibold tracking-wide text-foreground backdrop-blur-sm transition-colors hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
badgeClassName, badgeClassName,
)} )}
onMouseEnter={() => setMotionActive(true)} onMouseEnter={() => setMotionActive(true)}
...@@ -64,10 +65,16 @@ const MotionPhotoPreview = ({ ...@@ -64,10 +65,16 @@ const MotionPhotoPreview = ({
} }
}} }}
onPointerCancel={() => setMotionActive(false)} onPointerCancel={() => setMotionActive(false)}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
setMotionActive((prev) => !prev);
}
}}
aria-label="Hover or press to play live photo" aria-label="Hover or press to play live photo"
> >
LIVE LIVE
</button> </div>
</div> </div>
); );
}; };
......
import { X } from "lucide-react"; import { ChevronLeft, ChevronRight, X } from "lucide-react";
import React, { useEffect, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import MotionPhotoPreview from "@/components/MotionPhotoPreview"; import MotionPhotoPreview from "@/components/MotionPhotoPreview";
import { Button } from "@/components/ui/button"; 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"; import type { PreviewMediaItem } from "@/utils/media-item";
interface Props { interface Props {
...@@ -14,115 +17,216 @@ interface Props { ...@@ -14,115 +17,216 @@ interface Props {
} }
function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) { function PreviewImageDialog({ open, onOpenChange, imgUrls = [], items, initialIndex = 0 }: Props) {
const sm = useMediaQuery("sm");
const [currentIndex, setCurrentIndex] = useState(initialIndex); const [currentIndex, setCurrentIndex] = useState(initialIndex);
const previewItems = const previewItems = useMemo(
items ?? imgUrls.map((url) => ({ id: url, kind: "image" as const, sourceUrl: url, posterUrl: url, filename: "Image" })); () => 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(() => { useEffect(() => {
setCurrentIndex(initialIndex); if (open) {
}, [initialIndex]); 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(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (!open) return; if (!open) {
return;
switch (event.key) { }
case "Escape":
onOpenChange(false); if (event.key === "Escape") {
break; onOpenChange(false);
case "ArrowRight": return;
setCurrentIndex((prev) => Math.min(prev + 1, previewItems.length - 1)); }
break;
case "ArrowLeft": if (event.key === "ArrowLeft") {
setCurrentIndex((prev) => Math.max(prev - 1, 0)); setCurrentIndex((prev) => Math.max(prev - 1, 0));
break; return;
default: }
break;
if (event.key === "ArrowRight") {
setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1));
} }
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [open, onOpenChange]); }, [itemCount, onOpenChange, open]);
const handleClose = () => {
onOpenChange(false);
};
const handleBackdropClick = (event: React.MouseEvent<HTMLDivElement>) => {
if (event.target === event.currentTarget) {
handleClose();
}
};
// Return early if no images provided if (!itemCount || !currentItem) {
if (!previewItems.length) return null; return null;
}
// Ensure currentIndex is within bounds const handleClose = () => onOpenChange(false);
const safeIndex = Math.max(0, Math.min(currentIndex, previewItems.length - 1)); const handlePrevious = () => setCurrentIndex((prev) => Math.max(prev - 1, 0));
const currentItem = previewItems[safeIndex]; const handleNext = () => setCurrentIndex((prev) => Math.min(prev + 1, itemCount - 1));
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent <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" aria-describedby="image-preview-description"
> >
{/* Close button */} <VisuallyHidden>
<div className="fixed top-4 right-4 z-50"> <DialogTitle>{currentItem.filename || "Attachment preview"}</DialogTitle>
<Button </VisuallyHidden>
onClick={handleClose}
variant="secondary" <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">
size="icon" <div className="flex items-start justify-between gap-3">
className="rounded-full bg-popover/20 hover:bg-popover/30 border-border/20 backdrop-blur-sm" <div className="min-w-0 text-white">
aria-label="Close image preview" <div className="truncate text-sm font-medium">{currentItem.filename || "Attachment"}</div>
> {hasMultiple && (
<X className="h-4 w-4 text-popover-foreground" /> <div className="mt-1 text-xs text-white/70">
</Button> {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> </div>
{/* Image container */} <div
<div className="w-full h-full flex items-center justify-center p-4 sm:p-8 overflow-auto" onClick={handleBackdropClick}> 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"
{currentItem.kind === "video" ? ( onClick={(event) => {
<video if (event.target === event.currentTarget) {
key={currentItem.id} handleClose();
src={currentItem.sourceUrl} }
poster={currentItem.posterUrl} }}
className="max-w-full max-h-full object-contain" >
controls <div className="flex max-h-full max-w-full items-center justify-center" onClick={(event) => event.stopPropagation()}>
autoPlay {currentItem.kind === "video" ? (
/> <video
) : currentItem.kind === "motion" ? ( key={currentItem.id}
<MotionPhotoPreview src={currentItem.sourceUrl}
key={currentItem.id} poster={currentItem.posterUrl}
posterUrl={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)]"
motionUrl={currentItem.motionUrl} controls
alt={`Preview live photo ${safeIndex + 1} of ${previewItems.length}`} autoPlay
presentationTimestampUs={currentItem.presentationTimestampUs} playsInline
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)]" ) : 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" />}
/> />
) : ( <NavButton
<img side="right"
src={currentItem.sourceUrl} disabled={!canGoNext}
alt={`Preview image ${safeIndex + 1} of ${previewItems.length}`} label="Next item"
className="max-w-full max-h-full object-contain select-none" onClick={handleNext}
draggable={false} icon={<ChevronRight className="h-5 w-5" />}
loading="eager"
decoding="async"
/> />
)} </>
</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"> <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> </div>
</DialogContent> </DialogContent>
</Dialog> </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; export default PreviewImageDialog;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment