refactor: update Reaction component and integrate ReactionRail into PhotoViewer

- Refactored the Reaction component to improve structure and styling, renaming it to ReactionRail.
- Simplified the reaction handling logic and enhanced the UI for displaying reactions.
- Integrated ReactionRail into the PhotoViewer component to allow users to react to photos directly.
- Removed unused imports and optimized the code for better performance.

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei
2025-12-05 19:17:38 +08:00
parent a4927c5240
commit d0c420ba93
2 changed files with 119 additions and 191 deletions

View File

@@ -1,14 +1,11 @@
import { clsxm, Spring } from '@afilmory/utils'
import { clsxm } from '@afilmory/utils'
import { FluentEmoji, getEmoji } from '@lobehub/fluent-emoji'
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { produce } from 'immer'
import { AnimatePresence, m } from 'motion/react'
import type { CSSProperties, RefObject } from 'react'
import { useCallback, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { tv } from 'tailwind-variants'
import { useOnClickOutside } from 'usehooks-ts'
import { client } from '~/lib/client'
@@ -16,212 +13,141 @@ import { useAnalysis } from '../viewer/hooks/useAnalysis'
const reactions = ['👍', '😍', '🔥', '👏', '🌟', '🙌'] as const
interface ReactionButtonProps {
interface ReactionRailProps {
className?: string
disabled?: boolean
photoId: string
style?: CSSProperties
}
const reactionButton = tv({
const reactionRail = tv({
slots: {
base: 'relative **:data-radix-popper-content-wrapper:z-2',
mainButton: [
'relative z-2 flex size-10 items-center justify-center rounded-full',
'border border-border/50 backdrop-blur-2xl',
'transition-all duration-300',
root: 'pointer-events-auto absolute bottom-2 right-2 z-20 flex justify-center',
track: [
'flex flex-row items-center gap-2 px-2 py-1.5',
'transition-all duration-200 ease-out',
'opacity-0 translate-y-2 pointer-events-none',
'data-[visible=true]:opacity-100 data-[visible=true]:translate-y-0 data-[visible=true]:pointer-events-auto',
'group-hover/photo-viewer:opacity-100 group-hover/photo-viewer:translate-y-0 group-hover/photo-viewer:pointer-events-auto',
],
item: [
'group/reaction-item relative flex size-11 items-center justify-center rounded-2xl',
'bg-white/1 text-xl text-white/60 backdrop-blur-sm',
'transition-all duration-300 ease-out',
'hover:-translate-y-1 hover:scale-110 hover:bg-white/12 hover:text-white hover:backdrop-blur-lg',
'active:scale-95',
'disabled:cursor-not-allowed disabled:opacity-50',
'bg-background/95',
'data-[active=true]:bg-accent/18 data-[active=true]:text-accent data-[active=true]:backdrop-blur-xl',
'disabled:pointer-events-none disabled:opacity-40',
],
mainButtonIcon: 'text-lg',
reactionsContainer: [
'relative mb-4 flex items-center justify-center gap-2',
'rounded-full border border-accent/20 p-2 backdrop-blur-2xl',
'select-none',
],
reactionItem: ['relative flex size-10 items-center justify-center', 'cursor-pointer text-xl'],
count:
'absolute -right-1 -top-0.5 rounded-full bg-black/40 px-1.5 py-0.5 text-[10px] font-medium text-white/95 backdrop-blur-md',
},
})
const emojiContainerVariants = {
open: {
opacity: 1,
scale: 1,
y: 0,
transition: Spring.presets.snappy,
},
closed: {
opacity: 0,
scale: 0.2,
y: 50,
transition: Spring.presets.snappy,
},
}
const emojiVariants = {
open: {
opacity: 1,
y: 0,
scale: 1,
transition: Spring.presets.snappy,
},
closed: {
opacity: 0,
y: 10,
scale: 0.8,
transition: Spring.presets.snappy,
},
}
const iconVariants = {
open: { rotate: 180 },
closed: { rotate: 0 },
}
export const ReactionButton = ({ className, disabled = false, photoId, style }: ReactionButtonProps) => {
const [panelElement, setPanelElement] = useState<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = useState(false)
const styles = reactionButton()
export const ReactionRail = ({ className, disabled = false, photoId, style }: ReactionRailProps) => {
const styles = reactionRail()
const { t } = useTranslation()
const handleReaction = useCallback(
async (reaction: (typeof reactions)[number]) => {
await client.actReaction({
refKey: photoId,
reaction,
})
toast.success(t('photo.reaction.success'))
},
[photoId, t],
)
const { data, mutate } = useAnalysis(photoId)
const handleReactionClick = useCallback(
(reaction: (typeof reactions)[number]) => {
handleReaction(reaction).then(() => {
mutate((data) => {
return produce(data, (draft) => {
if (!draft) return
draft.data.reactions[reaction] = (draft.data.reactions[reaction] || 0) + 1
const [activeReactions, setActiveReactions] = useState<Set<(typeof reactions)[number]>>(() => new Set())
const activeReactionsRef = useRef(activeReactions)
useEffect(() => {
activeReactionsRef.current = activeReactions
}, [activeReactions])
const applyDelta = useCallback(
(reaction: (typeof reactions)[number], delta: number) => {
mutate(
(current) => {
if (!current) return current
return produce(current, (draft) => {
const next = Math.max(0, (draft.data.reactions[reaction] || 0) + delta)
if (next === 0) {
delete draft.data.reactions[reaction]
return
}
draft.data.reactions[reaction] = next
})
})
})
setIsOpen(false)
},
{ revalidate: false },
)
},
[handleReaction, mutate],
[mutate],
)
useOnClickOutside({ current: panelElement } as RefObject<HTMLElement>, () => {
setIsOpen(false)
})
const sendReaction = useCallback(
async (reaction: (typeof reactions)[number]) => {
try {
await client.actReaction({
refKey: photoId,
reaction,
})
toast.success(t('photo.reaction.success'))
} catch (error) {
console.error('Failed to send reaction', error)
toast.error('Failed to send reaction')
applyDelta(reaction, -1)
setActiveReactions((prev) => {
const next = new Set(prev)
next.delete(reaction)
return next
})
}
},
[applyDelta, photoId, t],
)
const [currentAnimatingEmoji, setCurrentAnimatingEmoji] = useState<(typeof reactions)[number] | null>(null)
const toggleReaction = useCallback(
(reaction: (typeof reactions)[number]) => {
const isActive = activeReactionsRef.current.has(reaction)
const delta = isActive ? -1 : 1
const animationTimeoutRef = useRef<NodeJS.Timeout | null>(null)
setActiveReactions((prev) => {
const next = new Set(prev)
if (isActive) {
next.delete(reaction)
} else {
next.add(reaction)
}
return next
})
applyDelta(reaction, delta)
if (!isActive) {
void sendReaction(reaction)
}
},
[applyDelta, sendReaction],
)
return (
<div className={clsxm(styles.base(), className)} style={style}>
<DropdownMenu.Root open={isOpen}>
<DropdownMenu.Trigger asChild>
<m.button
className={styles.mainButton()}
disabled={disabled}
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
aria-expanded={isOpen}
aria-label="React to photo"
initial="closed"
onClick={() => {
setIsOpen((prev) => !prev)
}}
exit={{
opacity: 0,
scale: 0,
transition: { duration: 0.2 },
}}
animate={isOpen ? 'open' : 'closed'}
>
<AnimatePresence>
{currentAnimatingEmoji ? (
<m.span
initial={{ scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0, transition: { duration: 0.3 } }}
transition={Spring.presets.snappy}
onAnimationComplete={() => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current)
}
animationTimeoutRef.current = setTimeout(() => {
setCurrentAnimatingEmoji(null)
}, 1000)
}}
>
<FluentEmoji cdn="aliyun" emoji={getEmoji(currentAnimatingEmoji)!} size={24} type="anim" />
</m.span>
) : (
<m.div variants={iconVariants}>
<AnimatePresence mode="popLayout">
<m.i
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0 } }}
transition={Spring.presets.smooth}
key={isOpen ? 'close' : 'emoji'}
className={isOpen ? 'i-mingcute-close-fill' : 'i-mingcute-emoji-fill'}
/>
</AnimatePresence>
</m.div>
)}
</AnimatePresence>
</m.button>
</DropdownMenu.Trigger>
<div className={clsxm(styles.root(), className)} style={style}>
<div className="group/rail relative flex w-full justify-center">
<div className={styles.track()}>
{reactions.map((reaction) => {
const count = data?.data.reactions[reaction]
const isActive = activeReactions.has(reaction)
<DropdownMenu.Content side="top" align="center" forceMount asChild>
<AnimatePresence>
{isOpen && (
<m.div
ref={setPanelElement}
variants={emojiContainerVariants}
initial="closed"
animate="open"
exit="closed"
className={styles.reactionsContainer()}
style={{
backgroundImage:
'linear-gradient(to bottom right, color-mix(in srgb, var(--color-background) 98%, transparent), color-mix(in srgb, var(--color-background) 95%, transparent))',
boxShadow:
'0 8px 32px color-mix(in srgb, var(--color-accent) 8%, transparent), 0 4px 16px color-mix(in srgb, var(--color-accent) 6%, transparent), 0 2px 8px rgba(0, 0, 0, 0.1)',
}}
return (
<button
key={reaction}
type="button"
className={styles.item()}
data-active={isActive}
disabled={disabled}
onClick={() => toggleReaction(reaction)}
aria-pressed={isActive}
aria-label={`React with ${reaction}`}
>
{reactions.map((reaction) => (
<DropdownMenu.Item key={reaction} asChild>
<m.button
className={styles.reactionItem()}
variants={emojiVariants}
onClick={() => {
if (animationTimeoutRef.current) {
clearTimeout(animationTimeoutRef.current)
}
setCurrentAnimatingEmoji(reaction)
handleReactionClick(reaction)
}}
whileHover={{ scale: 1.2 }}
whileTap={{ scale: 0.9 }}
>
<FluentEmoji cdn="aliyun" emoji={getEmoji(reaction)!} size={24} type="anim" />
{!!data?.data.reactions[reaction] && (
<span className="bg-red/50 absolute top-0 right-0 rounded-full px-1.5 py-0.5 text-[8px] text-white tabular-nums backdrop-blur-2xl">
{data.data.reactions[reaction]}
</span>
)}
</m.button>
</DropdownMenu.Item>
))}
</m.div>
)}
</AnimatePresence>
</DropdownMenu.Content>
</DropdownMenu.Root>
<FluentEmoji cdn="aliyun" emoji={getEmoji(reaction)!} size={24} type="anim" />
{typeof count === 'number' && count > 0 && <span className={styles.count()}>{count}</span>}
</button>
)
})}
</div>
</div>
</div>
)
}

View File

@@ -19,6 +19,7 @@ import { PhotoInspector } from '~/modules/inspector/PhotoInspector'
import { ShareModal } from '~/modules/social/ShareModal'
import type { PhotoManifest } from '~/types/photo'
import { ReactionRail } from '../social'
import { PhotoViewerTransitionPreview } from './animations/PhotoViewerTransitionPreview'
import { usePhotoViewerTransitions } from './animations/usePhotoViewerTransitions'
import { GalleryThumbnail } from './GalleryThumbnail'
@@ -206,7 +207,7 @@ export const PhotoViewer = ({
<div className={`flex size-full ${isMobile ? 'flex-col' : 'flex-row'}`}>
<div className="z-1 flex min-h-0 min-w-0 flex-1 flex-col">
<m.div
className="group relative flex min-h-0 min-w-0 flex-1"
className="group/photo-viewer relative flex min-h-0 min-w-0 flex-1"
animate={{ opacity: isViewerContentVisible ? 1 : 0 }}
transition={Spring.presets.snappy}
>
@@ -301,6 +302,7 @@ export const PhotoViewer = ({
const hideCurrentImage = isEntryAnimating && isCurrentImage
return (
<SwiperSlide key={photo.id} className="flex items-center justify-center" virtualIndex={index}>
<ReactionRail photoId={photo.id} />
<m.div
initial={{ opacity: 0.5, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
@@ -359,7 +361,7 @@ export const PhotoViewer = ({
{currentIndex > 0 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 left-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
className={`bg-material-medium absolute top-1/2 left-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handlePrevious}
>
<i className={`i-mingcute-left-line text-xl`} />
@@ -369,7 +371,7 @@ export const PhotoViewer = ({
{currentIndex < photos.length - 1 && (
<button
type="button"
className={`bg-material-medium absolute top-1/2 right-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover:opacity-100 hover:bg-black/40`}
className={`bg-material-medium absolute top-1/2 right-4 z-20 flex size-8 -translate-y-1/2 items-center justify-center rounded-full text-white opacity-0 backdrop-blur-sm duration-200 group-hover/photo-viewer:opacity-100 hover:bg-black/40`}
onClick={handleNext}
>
<i className={`i-mingcute-right-line text-xl`} />