mirror of
https://github.com/MoonTechLab/LunaTV.git
synced 2026-02-22 02:24:44 +08:00
feat(components): 添加图片骨架屏组件并优化卡片加载体验
实现图片加载时的骨架屏效果,支持暗色模式 在AggregateCard、VideoCard和DemoCard中应用骨架屏 优化图片加载动画和状态管理
This commit is contained in:
@@ -4,6 +4,8 @@ import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
// 聚合卡需要的基本字段,与搜索接口保持一致
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
@@ -64,6 +66,7 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
// 使用列表中的第一个结果做展示 & 播放
|
||||
const first = items[0];
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// 统计 items 中出现次数最多的(非 0) douban_id,用于跳转豆瓣页面
|
||||
@@ -124,11 +127,21 @@ const AggregateCard: React.FC<AggregateCardProps> = ({
|
||||
<div className='group relative w-full rounded-lg bg-transparent flex flex-col cursor-pointer transition-all duration-300 ease-in-out'>
|
||||
{/* 封面图片 2:3 */}
|
||||
<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)'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
<Image
|
||||
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'
|
||||
loading='lazy'
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Link as LinkIcon, Search } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React, { useState } from 'react';
|
||||
import React, { useRef, useState } from 'react';
|
||||
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
interface DemoCardProps {
|
||||
id: string;
|
||||
@@ -46,7 +48,9 @@ function SearchCircle({
|
||||
|
||||
const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
const [hover, setHover] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const router = useRouter();
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
const handleClick = () => {
|
||||
router.push(`/aggregate?q=${encodeURIComponent(title)}&type=${type}`);
|
||||
@@ -59,18 +63,30 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
>
|
||||
{/* 海报图片区域 */}
|
||||
<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'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
{/* 图片组件 */}
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className='object-cover transition-transform duration-500 cubic-bezier(0.4, 0, 0.2, 1) group-hover:scale-110'
|
||||
ref={imgRef}
|
||||
loading='lazy'
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{/* 评分徽章 */}
|
||||
{/* 评分徽章 - 暗色模式优化 */}
|
||||
{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 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'>
|
||||
<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'>
|
||||
{rate}
|
||||
</span>
|
||||
@@ -78,7 +94,7 @@ 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-300 cubic-bezier(0.4, 0, 0.2, 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
|
||||
onMouseEnter={() => setHover(true)}
|
||||
onMouseLeave={() => setHover(false)}
|
||||
@@ -90,7 +106,7 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 外部链接按钮 */}
|
||||
{/* 外部链接按钮 - 暗色模式优化 */}
|
||||
<a
|
||||
href={`https://movie.douban.com/subject/${id}`}
|
||||
target='_blank'
|
||||
@@ -98,15 +114,17 @@ const DemoCard = ({ id, title, poster, rate, type }: DemoCardProps) => {
|
||||
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)'
|
||||
>
|
||||
<div className='w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-[#22c55e] 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]'>
|
||||
<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} />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* 信息层 */}
|
||||
<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'>
|
||||
{title}
|
||||
{/* 信息层 - 暗色模式优化 */}
|
||||
<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'>
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
40
src/components/ImagePlaceholder.tsx
Normal file
40
src/components/ImagePlaceholder.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
// 图片占位符组件 - 实现骨架屏效果(支持暗色模式)
|
||||
const ImagePlaceholder = ({ aspectRatio }: { aspectRatio: string }) => (
|
||||
<div
|
||||
className={`w-full ${aspectRatio} rounded-md overflow-hidden`}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(90deg, var(--skeleton-color) 25%, var(--skeleton-highlight) 50%, var(--skeleton-color) 75%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shine 1.5s infinite',
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
@keyframes shine {
|
||||
0% { background-position: -200% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
/* 亮色模式变量 */
|
||||
:root {
|
||||
--skeleton-color: #f0f0f0;
|
||||
--skeleton-highlight: #e0e0e0;
|
||||
}
|
||||
|
||||
/* 暗色模式变量 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
}
|
||||
|
||||
.dark {
|
||||
--skeleton-color: #2d2d2d;
|
||||
--skeleton-highlight: #3d3d3d;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { ImagePlaceholder };
|
||||
@@ -6,6 +6,8 @@ import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { deletePlayRecord, isFavorited, toggleFavorite } from '@/lib/db.client';
|
||||
|
||||
import { ImagePlaceholder } from '@/components/ImagePlaceholder';
|
||||
|
||||
interface VideoCardProps {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -89,6 +91,7 @@ export default function VideoCard({
|
||||
}: VideoCardProps) {
|
||||
const [playHover, setPlayHover] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
@@ -161,15 +164,24 @@ export default function VideoCard({
|
||||
>
|
||||
{/* 海报图片容器 */}
|
||||
<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)'>
|
||||
{/* 图片占位符 - 骨架屏效果 */}
|
||||
<ImagePlaceholder aspectRatio='aspect-[2/3]' />
|
||||
|
||||
<Image
|
||||
src={poster}
|
||||
alt={title}
|
||||
fill
|
||||
className='object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110'
|
||||
loading='lazy'
|
||||
className={`object-cover transition-transform duration-500 cubic-bezier(0.4,0,0.2,1) group-hover:scale-110
|
||||
${
|
||||
isLoaded
|
||||
? 'opacity-100 scale-100'
|
||||
: 'opacity-0 scale-95'
|
||||
}`}
|
||||
onLoadingComplete={() => setIsLoaded(true)}
|
||||
referrerPolicy='no-referrer'
|
||||
priority={false}
|
||||
/>
|
||||
|
||||
{/* Hover 效果层 */}
|
||||
<div
|
||||
className={`absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent ${
|
||||
@@ -230,7 +242,6 @@ export default function VideoCard({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 继续观看 - 集数矩形展示框 */}
|
||||
{episodes && episodes > 1 && currentEpisode && (
|
||||
<div className='absolute top-2 right-2 min-w-[1.875rem] h-5 sm:h-7 sm:min-w-[2.5rem] bg-green-500/90 dark:bg-green-600/90 rounded-md flex items-center justify-center px-2 shadow-md text-[0.55rem] sm:text-xs'>
|
||||
@@ -243,7 +254,6 @@ export default function VideoCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 搜索非聚合 - 集数圆形展示框 */}
|
||||
{from === 'search' && (
|
||||
<div className='absolute top-2 right-2 w-4 h-4 sm:w-7 sm:h-7 rounded-full bg-green-500/90 dark:bg-green-600/90 flex items-center justify-center shadow-md text-[0.55rem] sm:text-xs'>
|
||||
@@ -252,7 +262,6 @@ export default function VideoCard({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 豆瓣链接按钮 */}
|
||||
{douban_id && from === 'search' && (
|
||||
<a
|
||||
|
||||
Reference in New Issue
Block a user