mirror of
https://github.com/Afilmory/afilmory
synced 2026-02-01 22:48:17 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`} />
|
||||
|
||||
Reference in New Issue
Block a user