From c924fc5f6cf3615f1b9d511e1e7f3f4f910fbd56 Mon Sep 17 00:00:00 2001 From: shinya Date: Sat, 5 Jul 2025 01:47:44 +0800 Subject: [PATCH] feat: card hover style --- src/components/AggregateCard.tsx | 77 ++++++++++++++++++-- src/components/DemoCard.tsx | 119 ++++++++++++++++++++++++++----- src/components/VideoCard.tsx | 78 +++++++++++++++++--- 3 files changed, 242 insertions(+), 32 deletions(-) diff --git a/src/components/AggregateCard.tsx b/src/components/AggregateCard.tsx index ca5730f..f9bb1c7 100644 --- a/src/components/AggregateCard.tsx +++ b/src/components/AggregateCard.tsx @@ -1,8 +1,10 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; @@ -69,6 +71,55 @@ const AggregateCard: React.FC = ({ const [isLoaded, setIsLoaded] = useState(false); const router = useRouter(); + // 视差位置偏移 + const [parallax, setParallax] = useState({ x: 0, y: 0 }); + const cardRef = useRef(null); + + // 图片视差效果 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); + // 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面 const mostFrequentDoubanId = useMemo(() => { const countMap = new Map(); @@ -124,7 +175,10 @@ const AggregateCard: React.FC = ({ year ? `&year=${encodeURIComponent(year)}` : '' }&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`} > -
+
{/* 封面图片 2:3 */}
{/* 图片占位符 - 骨架屏效果 */} @@ -134,7 +188,7 @@ const AggregateCard: React.FC = ({ src={first.poster} alt={first.title} fill - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) ${ isLoaded ? 'opacity-100 scale-100' @@ -143,6 +197,13 @@ const AggregateCard: React.FC = ({ onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34,1.56,0.64,1)', + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* Hover 效果层 */} @@ -150,8 +211,8 @@ const AggregateCard: React.FC = ({ {/* 播放按钮 */}
{ @@ -199,8 +260,10 @@ const AggregateCard: React.FC = ({
{/* 标题 */} - - {first.title} + + + {first.title} +
diff --git a/src/components/DemoCard.tsx b/src/components/DemoCard.tsx index f5a19ee..04229cf 100644 --- a/src/components/DemoCard.tsx +++ b/src/components/DemoCard.tsx @@ -1,7 +1,9 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { Link as LinkIcon, Search } from 'lucide-react'; import Image from 'next/image'; import { useRouter } from 'next/navigation'; -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { ImagePlaceholder } from '@/components/ImagePlaceholder'; @@ -13,12 +15,15 @@ interface DemoCardProps { type?: string; } +// 优化的搜索图标组件,添加更多动画 function SearchCircle({ className = '', fillColor = 'none', + isHovered = false, }: { className?: string; fillColor?: string; + isHovered?: boolean; }) { return (
- +
@@ -49,8 +66,59 @@ function SearchCircle({ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { const [hover, setHover] = useState(false); const [isLoaded, setIsLoaded] = useState(false); + const [parallax, setParallax] = useState({ x: 0, y: 0 }); const router = useRouter(); const imgRef = useRef(null); + const cardRef = useRef(null); + + // 图片视差效果 - 优化 Safari 性能 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 只有当移动超过阈值时才更新视差,减少 Safari 中的重绘 + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + // 使用 requestAnimationFrame 优化性能 + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + // 计算视差偏移量 (-5 到 5 之间) + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); const handleClick = () => { router.push( @@ -60,11 +128,19 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { return (
{/* 海报图片区域 */} -
+
{/* 图片占位符 - 骨架屏效果 */} @@ -74,20 +150,28 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { alt={title} fill ref={imgRef} - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34, 1.56, 0.64, 1) ${ isLoaded - ? 'opacity-100 scale-100' - : 'opacity-0 scale-95' + ? 'opacity-100 scale-100 blur-0' + : 'opacity-0 scale-95 blur-sm' }`} onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1)', + // 修复 Safari 中可能的渲染问题 + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* 评分徽章 - 暗色模式优化 */} {rate && ( -
+
{rate} @@ -95,15 +179,18 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { )} {/* 悬浮层 - 搜索按钮 */} -
+
setHover(true)} onMouseLeave={() => setHover(false)} - className={`transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) ${ - hover ? 'scale-110 rotate-12' : 'scale-90' + className={`transition-all duration-300 cubic-bezier(0.34, 1.56, 0.64, 1) ${ + hover ? 'scale-110' : 'scale-90' }`} > - +
@@ -113,16 +200,16 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => { target='_blank' rel='noopener noreferrer' onClick={(e) => e.stopPropagation()} - className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1)' + className='absolute top-2 left-2 scale-90 group-hover:scale-100 opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:translate-y-0 translate-y-[-10px]' > -
+
{/* 信息层 - 暗色模式优化 */} - + {title} diff --git a/src/components/VideoCard.tsx b/src/components/VideoCard.tsx index 5036ede..dc0ebc5 100644 --- a/src/components/VideoCard.tsx +++ b/src/components/VideoCard.tsx @@ -1,8 +1,10 @@ +/* eslint-disable react-hooks/exhaustive-deps */ + import { Heart, Link as LinkIcon } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client'; @@ -93,6 +95,8 @@ export default function VideoCard({ const [favorited, setFavorited] = useState(false); const [isLoaded, setIsLoaded] = useState(false); const [isDeleting, setIsDeleting] = useState(false); + const [parallax, setParallax] = useState({ x: 0, y: 0 }); + const cardRef = useRef(null); const router = useRouter(); // 检查初始收藏状态 @@ -148,6 +152,52 @@ export default function VideoCard({ } }; + // 图片视差效果 - 参考 DemoCard,优化 Safari 性能 + useEffect(() => { + let requestId: number | null = null; + let lastX = 0; + let lastY = 0; + + const handleMouseMove = (e: MouseEvent) => { + if (!cardRef.current) return; + + const rect = cardRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 只有在移动超过阈值时才更新,减少重绘 + if (Math.abs(x - lastX) > 5 || Math.abs(y - lastY) > 5) { + lastX = x; + lastY = y; + + if (requestId) cancelAnimationFrame(requestId); + requestId = requestAnimationFrame(() => { + const xParallax = (x / rect.width - 0.5) * 10; + const yParallax = (y / rect.height - 0.5) * 10; + setParallax({ x: xParallax, y: yParallax }); + }); + } + }; + + const handleMouseLeave = () => { + if (requestId) cancelAnimationFrame(requestId); + setParallax({ x: 0, y: 0 }); + }; + + if (cardRef.current) { + cardRef.current.addEventListener('mousemove', handleMouseMove); + cardRef.current.addEventListener('mouseleave', handleMouseLeave); + } + + return () => { + if (cardRef.current) { + cardRef.current.removeEventListener('mousemove', handleMouseMove); + cardRef.current.removeEventListener('mouseleave', handleMouseLeave); + } + if (requestId) cancelAnimationFrame(requestId); + }; + }, []); + const hideCheckCircle = from === 'favorites' || from === 'search'; const alwaysShowHeart = from !== 'favorites'; @@ -158,7 +208,8 @@ export default function VideoCard({ )}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`} >
@@ -171,7 +222,7 @@ export default function VideoCard({ src={poster} alt={title} fill - className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110 + className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) ${ isLoaded ? 'opacity-100 scale-100' @@ -180,6 +231,13 @@ export default function VideoCard({ onLoadingComplete={() => setIsLoaded(true)} referrerPolicy='no-referrer' priority={false} + style={{ + transform: `scale(1.05) translate(${parallax.x}px, ${parallax.y}px)`, + transition: 'transform 0.5s cubic-bezier(0.34,1.56,0.64,1)', + willChange: 'transform', + backfaceVisibility: 'hidden', + perspective: '1000px', + }} /> {/* Hover 效果层 */}
{ @@ -287,14 +345,16 @@ export default function VideoCard({
)} - {/* 信息层 */} - - {title} + {/* 信息层 - 与 DemoCard 对齐的动画 */} + + + {title} + {/* 来源信息 */} {source && ( - + {source_name}