Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in
Toggle navigation
C
canifa_note
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Vũ Hoàng Anh
canifa_note
Commits
aafcc21a
Commit
aafcc21a
authored
Apr 06, 2026
by
boojack
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
fix: improve image preview dialog and live photo trigger
parent
6b0487dc
Changes
2
Hide whitespace changes
Inline
Side-by-side
Showing
2 changed files
with
198 additions
and
87 deletions
+198
-87
MotionPhotoPreview.tsx
web/src/components/MotionPhotoPreview.tsx
+11
-4
PreviewImageDialog.tsx
web/src/components/PreviewImageDialog.tsx
+187
-83
No files found.
web/src/components/MotionPhotoPreview.tsx
View file @
aafcc21a
...
@@ -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
>
);
);
};
};
...
...
web/src/components/PreviewImageDialog.tsx
View file @
aafcc21a
import
{
X
}
from
"lucide-react"
;
import
{
ChevronLeft
,
ChevronRight
,
X
}
from
"lucide-react"
;
import
React
,
{
useEffect
,
useState
}
from
"react"
;
import
React
,
{
useEffect
,
use
Memo
,
use
State
}
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
;
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment