mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-03-16 11:37:29 +08:00
@@ -1,10 +1,8 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
import { LinkIcon } from 'lucide-react';
|
import { LinkIcon } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
|
|
||||||
@@ -71,55 +69,6 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 视差位置偏移
|
|
||||||
const [parallax, setParallax] = useState({ x: 0, y: 0 });
|
|
||||||
const cardRef = useRef<HTMLDivElement>(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,用于跳转豆瓣页面
|
// 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面
|
||||||
const mostFrequentDoubanId = useMemo(() => {
|
const mostFrequentDoubanId = useMemo(() => {
|
||||||
const countMap = new Map<number, number>();
|
const countMap = new Map<number, number>();
|
||||||
@@ -175,10 +124,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
year ? `&year=${encodeURIComponent(year)}` : ''
|
year ? `&year=${encodeURIComponent(year)}` : ''
|
||||||
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
|
}&type=${mostFrequentEpisodes > 1 ? 'tv' : 'movie'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'>
|
||||||
ref={cardRef}
|
|
||||||
className='group relative w-full rounded-lg overflow-hidden bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'
|
|
||||||
>
|
|
||||||
{/* 封面图片 2:3 */}
|
{/* 封面图片 2:3 */}
|
||||||
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md transition-all duration-400 cubic-bezier(0.4,0,0.2,1)'>
|
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md transition-all duration-400 cubic-bezier(0.4,0,0.2,1)'>
|
||||||
{/* 图片占位符 - 骨架屏效果 */}
|
{/* 图片占位符 - 骨架屏效果 */}
|
||||||
@@ -188,7 +134,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
src={first.poster}
|
src={first.poster}
|
||||||
alt={first.title}
|
alt={first.title}
|
||||||
fill
|
fill
|
||||||
className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) group-hover:scale-[1.05]
|
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||||
${
|
${
|
||||||
isLoaded
|
isLoaded
|
||||||
? 'opacity-100 scale-100'
|
? 'opacity-100 scale-100'
|
||||||
@@ -197,13 +143,6 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
onLoadingComplete={() => setIsLoaded(true)}
|
onLoadingComplete={() => setIsLoaded(true)}
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
priority={false}
|
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 效果层 */}
|
{/* Hover 效果层 */}
|
||||||
@@ -211,8 +150,8 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
{/* 播放按钮 */}
|
{/* 播放按钮 */}
|
||||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 cubic-bezier(0.34,1.56,0.64,1) ${
|
className={`transition-all duration-300 cubic-bezier(0.4,0,0.2,1) ${
|
||||||
playHover ? 'scale-110 opacity-100' : 'scale-90 opacity-70'
|
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
|
||||||
}`}
|
}`}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -260,10 +199,8 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<span className='mt-2 px-1 block font-semibold truncate w-full text-center text-xs sm:text-sm transition-all duration-500 cubic-bezier(0.34,1.56,0.64,1) group-hover:translate-y-[-4px] opacity-80 group-hover:opacity-100'>
|
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||||
<span className='text-gray-900 dark:text-gray-200 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
{first.title}
|
||||||
{first.title}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
import { Link as LinkIcon, Search } from 'lucide-react';
|
import { Link as LinkIcon, Search } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useRef, useState } from 'react';
|
||||||
|
|
||||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||||
|
|
||||||
@@ -15,15 +13,12 @@ interface DemoCardProps {
|
|||||||
type?: string;
|
type?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 优化的搜索图标组件,添加更多动画
|
|
||||||
function SearchCircle({
|
function SearchCircle({
|
||||||
className = '',
|
className = '',
|
||||||
fillColor = 'none',
|
fillColor = 'none',
|
||||||
isHovered = false,
|
|
||||||
}: {
|
}: {
|
||||||
className?: string;
|
className?: string;
|
||||||
fillColor?: string;
|
fillColor?: string;
|
||||||
isHovered?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -32,9 +27,7 @@ function SearchCircle({
|
|||||||
viewBox='0 0 44 44'
|
viewBox='0 0 44 44'
|
||||||
fill='none'
|
fill='none'
|
||||||
xmlns='http://www.w3.org/2000/svg'
|
xmlns='http://www.w3.org/2000/svg'
|
||||||
className={`${className} block relative transition-all duration-300 ${
|
className={`${className} block relative`}
|
||||||
isHovered ? 'scale-105' : 'scale-95'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
cx='22'
|
cx='22'
|
||||||
@@ -43,20 +36,10 @@ function SearchCircle({
|
|||||||
stroke='white'
|
stroke='white'
|
||||||
strokeWidth='1.5'
|
strokeWidth='1.5'
|
||||||
fill={fillColor}
|
fill={fillColor}
|
||||||
className='transition-all duration-300'
|
|
||||||
/>
|
/>
|
||||||
<foreignObject x='0' y='0' width='44' height='44'>
|
<foreignObject x='0' y='0' width='44' height='44'>
|
||||||
<div className='w-full h-full flex items-center justify-center'>
|
<div className='w-full h-full flex items-center justify-center'>
|
||||||
<Search
|
<Search className='h-5 w-5 text-white' strokeWidth={2} />
|
||||||
className='h-5 w-5 text-white transition-all duration-300'
|
|
||||||
strokeWidth={2}
|
|
||||||
style={{
|
|
||||||
transform: isHovered ? 'rotate(15deg)' : 'rotate(0)',
|
|
||||||
filter: isHovered
|
|
||||||
? 'drop-shadow(0 0 4px rgba(255,255,255,0.6))'
|
|
||||||
: 'none',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</foreignObject>
|
</foreignObject>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -66,59 +49,8 @@ function SearchCircle({
|
|||||||
const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||||
const [hover, setHover] = useState(false);
|
const [hover, setHover] = useState(false);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [parallax, setParallax] = useState({ x: 0, y: 0 });
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const imgRef = useRef<HTMLImageElement>(null);
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
const cardRef = useRef<HTMLDivElement>(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 = () => {
|
const handleClick = () => {
|
||||||
router.push(
|
router.push(
|
||||||
@@ -128,19 +60,11 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'
|
||||||
className='group relative w-full rounded-lg overflow-hidden bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'
|
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
{/* 海报图片区域 */}
|
{/* 海报图片区域 */}
|
||||||
<div
|
<div className='relative aspect-[2/3] w-full overflow-hidden rounded-md group-hover:scale-[1.02] transition-all duration-400 cubic-bezier(0.4, 0, 0.2, 1) safari-fix'>
|
||||||
className='relative w-full overflow-hidden rounded-md transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1) safari-fix'
|
|
||||||
style={{
|
|
||||||
// 为 Safari 提供固定宽高比的后备方案
|
|
||||||
paddingBottom: '150%', // 2:3 比例
|
|
||||||
height: '0',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 图片占位符 - 骨架屏效果 */}
|
{/* 图片占位符 - 骨架屏效果 */}
|
||||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||||
|
|
||||||
@@ -150,28 +74,20 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
|||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
ref={imgRef}
|
ref={imgRef}
|
||||||
className={`object-cover transition-all duration-700 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:scale-[1.05]
|
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||||
${
|
${
|
||||||
isLoaded
|
isLoaded
|
||||||
? 'opacity-100 scale-100 blur-0'
|
? 'opacity-100 scale-100'
|
||||||
: 'opacity-0 scale-95 blur-sm'
|
: 'opacity-0 scale-95'
|
||||||
}`}
|
}`}
|
||||||
onLoadingComplete={() => setIsLoaded(true)}
|
onLoadingComplete={() => setIsLoaded(true)}
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
priority={false}
|
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 && (
|
||||||
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 dark:bg-pink-400 rounded-full flex items-center justify-center px-1 shadow-md transform transition-all duration-300 cubic-bezier(0.34, 1.56, 0.64, 1) scale-100 group-hover:scale-110'>
|
<div className='absolute top-2 right-2 min-w-[1.25rem] h-4 w-4 sm:h-7 sm:w-7 sm:min-w-[1.5rem] bg-pink-500 dark:bg-pink-400 rounded-full flex items-center justify-center px-1 shadow-md transform transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110 group-hover:rotate-3'>
|
||||||
<span className='text-white text-[0.5rem] sm:text-xs font-bold leading-none'>
|
<span className='text-white text-[0.5rem] sm:text-xs font-bold leading-none'>
|
||||||
{rate}
|
{rate}
|
||||||
</span>
|
</span>
|
||||||
@@ -179,18 +95,15 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 悬浮层 - 搜索按钮 */}
|
{/* 悬浮层 - 搜索按钮 */}
|
||||||
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1) flex items-center justify-center'>
|
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 cubic-bezier(0.4, 0, 0.2, 1) flex items-center justify-center'>
|
||||||
<div
|
<div
|
||||||
onMouseEnter={() => setHover(true)}
|
onMouseEnter={() => setHover(true)}
|
||||||
onMouseLeave={() => setHover(false)}
|
onMouseLeave={() => setHover(false)}
|
||||||
className={`transition-all duration-300 cubic-bezier(0.34, 1.56, 0.64, 1) ${
|
className={`transition-all duration-300 cubic-bezier(0.4, 0, 0.2, 1) ${
|
||||||
hover ? 'scale-110' : 'scale-90'
|
hover ? 'scale-110 rotate-12' : 'scale-90'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<SearchCircle
|
<SearchCircle fillColor={hover ? '#22c55e' : 'none'} />
|
||||||
fillColor={hover ? '#22c55e' : 'none'}
|
|
||||||
isHovered={hover}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -200,16 +113,16 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
|||||||
target='_blank'
|
target='_blank'
|
||||||
rel='noopener noreferrer'
|
rel='noopener noreferrer'
|
||||||
onClick={(e) => e.stopPropagation()}
|
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-500 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:translate-y-0 translate-y-[-10px]'
|
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)'
|
||||||
>
|
>
|
||||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] dark:bg-[#16a34a] flex items-center justify-center shadow-md transition-all duration-300 ease-in-out hover:scale-110 hover:bg-[#16a34a] dark:hover:bg-[#15803d]'>
|
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] dark:bg-[#16a34a] flex items-center justify-center shadow-md opacity-70 hover:opacity-100 transition-all duration-200 ease-in-out hover:scale-110 hover:bg-[#16a34a] dark:hover:bg-[#15803d]'>
|
||||||
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
<LinkIcon className='w-4 h-4 text-white' strokeWidth={2} />
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信息层 - 暗色模式优化 */}
|
{/* 信息层 - 暗色模式优化 */}
|
||||||
<span className='mt-2 px-1 block font-semibold truncate w-full text-center text-xs sm:text-sm transition-all duration-500 cubic-bezier(0.34, 1.56, 0.64, 1) group-hover:translate-y-[-4px] opacity-80 group-hover:opacity-100'>
|
<span className='mt-2 px-1 block font-semibold truncate w-full text-center text-xs sm:text-sm transition-all duration-400 cubic-bezier(0.4, 0, 0.2, 1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
|
||||||
<span className='text-gray-900 dark:text-gray-200 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
<span className='text-gray-900 dark:text-gray-200 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
/* eslint-disable react-hooks/exhaustive-deps */
|
|
||||||
|
|
||||||
import { Heart, Link as LinkIcon } from 'lucide-react';
|
import { Heart, Link as LinkIcon } from 'lucide-react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client';
|
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client';
|
||||||
|
|
||||||
@@ -95,8 +93,6 @@ export default function VideoCard({
|
|||||||
const [favorited, setFavorited] = useState(false);
|
const [favorited, setFavorited] = useState(false);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
const [parallax, setParallax] = useState({ x: 0, y: 0 });
|
|
||||||
const cardRef = useRef<HTMLDivElement>(null);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
// 检查初始收藏状态
|
// 检查初始收藏状态
|
||||||
@@ -152,52 +148,6 @@ 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 hideCheckCircle = from === 'favorites' || from === 'search';
|
||||||
const alwaysShowHeart = from !== 'favorites';
|
const alwaysShowHeart = from !== 'favorites';
|
||||||
|
|
||||||
@@ -208,8 +158,7 @@ export default function VideoCard({
|
|||||||
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
|
)}${year ? `&year=${year}` : ''}${from ? `&from=${from}` : ''}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={cardRef}
|
className={`group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out ${
|
||||||
className={`group relative w-full rounded-lg overflow-hidden bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out ${
|
|
||||||
isDeleting ? 'opacity-0 scale-90' : ''
|
isDeleting ? 'opacity-0 scale-90' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -222,7 +171,7 @@ export default function VideoCard({
|
|||||||
src={poster}
|
src={poster}
|
||||||
alt={title}
|
alt={title}
|
||||||
fill
|
fill
|
||||||
className={`object-cover transition-all duration-700 cubic-bezier(0.34,1.56,0.64,1) group-hover:scale-[1.05]
|
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||||
${
|
${
|
||||||
isLoaded
|
isLoaded
|
||||||
? 'opacity-100 scale-100'
|
? 'opacity-100 scale-100'
|
||||||
@@ -231,13 +180,6 @@ export default function VideoCard({
|
|||||||
onLoadingComplete={() => setIsLoaded(true)}
|
onLoadingComplete={() => setIsLoaded(true)}
|
||||||
referrerPolicy='no-referrer'
|
referrerPolicy='no-referrer'
|
||||||
priority={false}
|
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 效果层 */}
|
{/* Hover 效果层 */}
|
||||||
<div
|
<div
|
||||||
@@ -250,8 +192,8 @@ export default function VideoCard({
|
|||||||
{/* 播放按钮 */}
|
{/* 播放按钮 */}
|
||||||
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
<div className='absolute inset-0 flex items-center justify-center pointer-events-auto'>
|
||||||
<div
|
<div
|
||||||
className={`transition-all duration-300 cubic-bezier(0.34,1.56,0.64,1) ${
|
className={`transition-all duration-300 cubic-bezier(0.4,0,0.2,1) ${
|
||||||
playHover ? 'scale-110 opacity-100' : 'scale-90 opacity-70'
|
playHover ? 'scale-100 opacity-100' : 'scale-90 opacity-70'
|
||||||
}`}
|
}`}
|
||||||
style={{ cursor: 'pointer' }}
|
style={{ cursor: 'pointer' }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -345,16 +287,14 @@ export default function VideoCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 信息层 - 与 DemoCard 对齐的动画 */}
|
{/* 信息层 */}
|
||||||
<span className='mt-2 px-1 block font-semibold truncate w-full text-center text-xs sm:text-sm transition-all duration-500 cubic-bezier(0.34,1.56,0.64,1) group-hover:translate-y-[-4px] opacity-80 group-hover:opacity-100'>
|
<span className='mt-2 px-1 block text-gray-900 font-semibold truncate w-full text-center text-xs sm:text-sm dark:text-gray-200 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
||||||
<span className='text-gray-900 dark:text-gray-200 group-hover:text-green-600 dark:group-hover:text-green-400'>
|
{title}
|
||||||
{title}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* 来源信息 */}
|
{/* 来源信息 */}
|
||||||
{source && (
|
{source && (
|
||||||
<span className='mt-1 px-1 block text-gray-500 text-[0.5rem] sm:text-xs w-full text-center dark:text-gray-400 transition-all duration-500 cubic-bezier(0.34,1.56,0.64,1) group-hover:translate-y-[-4px] opacity-80 group-hover:opacity-100'>
|
<span className='mt-1 px-1 block text-gray-500 text-[0.5rem] sm:text-xs w-full text-center dark:text-gray-400 transition-all duration-400 cubic-bezier(0.4,0,0.2,1) group-hover:translate-y-[-2px] translate-y-1 opacity-80 group-hover:opacity-100'>
|
||||||
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
|
<span className='inline-block border border-gray-500/60 rounded px-2 py-[1px] dark:border-gray-400/60'>
|
||||||
{source_name}
|
{source_name}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user