447 lines
15 KiB
TypeScript
447 lines
15 KiB
TypeScript
|
|
'use client';
|
||
|
|
|
||
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
||
|
|
import { useState } from 'react';
|
||
|
|
import { EyeIcon, ArrowDownTrayIcon, HeartIcon } from '@heroicons/react/24/outline';
|
||
|
|
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||
|
|
import SkinViewer from './SkinViewer';
|
||
|
|
import type { Texture } from '@/lib/api';
|
||
|
|
|
||
|
|
interface SkinCardProps {
|
||
|
|
texture: Texture;
|
||
|
|
isFavorited?: boolean;
|
||
|
|
onViewDetails: (texture: Texture) => void;
|
||
|
|
onToggleFavorite?: (textureId: number) => void;
|
||
|
|
onDownload?: (texture: Texture) => void;
|
||
|
|
showVisibilityBadge?: boolean;
|
||
|
|
showActions?: boolean;
|
||
|
|
customActions?: React.ReactNode;
|
||
|
|
index?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function SkinCard({
|
||
|
|
texture,
|
||
|
|
isFavorited = false,
|
||
|
|
onViewDetails,
|
||
|
|
onToggleFavorite,
|
||
|
|
onDownload,
|
||
|
|
showVisibilityBadge = true,
|
||
|
|
showActions = true,
|
||
|
|
customActions,
|
||
|
|
index = 0
|
||
|
|
}: SkinCardProps) {
|
||
|
|
const [isHovered, setIsHovered] = useState(false);
|
||
|
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
||
|
|
const [isFavoriting, setIsFavoriting] = useState(false);
|
||
|
|
|
||
|
|
const handleDownload = async () => {
|
||
|
|
if (isDownloading) return;
|
||
|
|
|
||
|
|
setIsDownloading(true);
|
||
|
|
|
||
|
|
// 模拟下载延迟
|
||
|
|
setTimeout(() => {
|
||
|
|
if (onDownload) {
|
||
|
|
onDownload(texture);
|
||
|
|
} else {
|
||
|
|
window.open(texture.url, '_blank');
|
||
|
|
}
|
||
|
|
setIsDownloading(false);
|
||
|
|
}, 500);
|
||
|
|
};
|
||
|
|
|
||
|
|
const handleToggleFavorite = async () => {
|
||
|
|
if (isFavoriting || !onToggleFavorite) return;
|
||
|
|
|
||
|
|
setIsFavoriting(true);
|
||
|
|
|
||
|
|
// 模拟收藏操作延迟
|
||
|
|
setTimeout(() => {
|
||
|
|
onToggleFavorite(texture.id);
|
||
|
|
setIsFavoriting(false);
|
||
|
|
}, 300);
|
||
|
|
};
|
||
|
|
|
||
|
|
const getCardVariants = () => ({
|
||
|
|
hidden: {
|
||
|
|
opacity: 0,
|
||
|
|
y: 50,
|
||
|
|
scale: 0.9,
|
||
|
|
rotateX: -15
|
||
|
|
},
|
||
|
|
visible: {
|
||
|
|
opacity: 1,
|
||
|
|
y: 0,
|
||
|
|
scale: 1,
|
||
|
|
rotateX: 0,
|
||
|
|
transition: {
|
||
|
|
duration: 0.6,
|
||
|
|
delay: index * 0.1,
|
||
|
|
ease: [0.25, 0.46, 0.45, 0.94],
|
||
|
|
type: "spring",
|
||
|
|
stiffness: 100,
|
||
|
|
damping: 15
|
||
|
|
}
|
||
|
|
},
|
||
|
|
hover: {
|
||
|
|
scale: 1.03,
|
||
|
|
y: -8,
|
||
|
|
rotateX: 5,
|
||
|
|
transition: {
|
||
|
|
duration: 0.3,
|
||
|
|
ease: "easeOut"
|
||
|
|
}
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const getActionButtonVariants = () => ({
|
||
|
|
initial: { scale: 0, opacity: 0 },
|
||
|
|
hover: {
|
||
|
|
scale: 1,
|
||
|
|
opacity: 1,
|
||
|
|
transition: {
|
||
|
|
duration: 0.2,
|
||
|
|
delay: 0.1,
|
||
|
|
type: "spring",
|
||
|
|
stiffness: 300,
|
||
|
|
damping: 20
|
||
|
|
}
|
||
|
|
},
|
||
|
|
tap: {
|
||
|
|
scale: 0.9,
|
||
|
|
transition: { duration: 0.1 }
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
const getTagVariants = () => ({
|
||
|
|
initial: { scale: 0.8, opacity: 0 },
|
||
|
|
animate: {
|
||
|
|
scale: 1,
|
||
|
|
opacity: 1,
|
||
|
|
transition: {
|
||
|
|
duration: 0.3,
|
||
|
|
delay: 0.2 + index * 0.05,
|
||
|
|
type: "spring",
|
||
|
|
stiffness: 200,
|
||
|
|
damping: 15
|
||
|
|
}
|
||
|
|
},
|
||
|
|
hover: {
|
||
|
|
scale: 1.05,
|
||
|
|
transition: { duration: 0.2 }
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
return (
|
||
|
|
<motion.div
|
||
|
|
variants={getCardVariants()}
|
||
|
|
initial="hidden"
|
||
|
|
animate="visible"
|
||
|
|
whileHover="hover"
|
||
|
|
onHoverStart={() => setIsHovered(true)}
|
||
|
|
onHoverEnd={() => setIsHovered(false)}
|
||
|
|
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-2xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
|
||
|
|
style={{
|
||
|
|
perspective: '1000px',
|
||
|
|
transformStyle: 'preserve-3d'
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{/* 3D预览区域 */}
|
||
|
|
<div className="relative aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 overflow-hidden">
|
||
|
|
{/* 加载状态 */}
|
||
|
|
<AnimatePresence>
|
||
|
|
{!imageLoaded && (
|
||
|
|
<motion.div
|
||
|
|
initial={{ opacity: 1 }}
|
||
|
|
exit={{ opacity: 0 }}
|
||
|
|
className="absolute inset-0 flex items-center justify-center bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-600 dark:to-gray-500"
|
||
|
|
>
|
||
|
|
<motion.div
|
||
|
|
animate={{
|
||
|
|
rotate: 360,
|
||
|
|
scale: [1, 1.1, 1]
|
||
|
|
}}
|
||
|
|
transition={{
|
||
|
|
rotate: { duration: 2, repeat: Infinity, ease: "linear" },
|
||
|
|
scale: { duration: 1.5, repeat: Infinity, ease: "easeInOut" }
|
||
|
|
}}
|
||
|
|
className="w-12 h-12 border-4 border-orange-300 dark:border-orange-600 border-t-transparent rounded-full"
|
||
|
|
/>
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
</AnimatePresence>
|
||
|
|
|
||
|
|
{texture.type === 'SKIN' ? (
|
||
|
|
<SkinViewer
|
||
|
|
skinUrl={texture.url}
|
||
|
|
isSlim={texture.is_slim}
|
||
|
|
width={300}
|
||
|
|
height={300}
|
||
|
|
className={`w-full h-full transition-all duration-500 ${
|
||
|
|
imageLoaded ? 'opacity-100 scale-100' : 'opacity-0 scale-95'
|
||
|
|
} ${isHovered ? 'scale-110' : ''}`}
|
||
|
|
autoRotate={isHovered}
|
||
|
|
walking={false}
|
||
|
|
onLoad={() => setImageLoaded(true)}
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
||
|
|
<motion.div
|
||
|
|
className="text-center"
|
||
|
|
animate={isHovered ? { y: [-5, 5, -5] } : {}}
|
||
|
|
transition={{ duration: 2, repeat: Infinity, ease: "easeInOut" }}
|
||
|
|
>
|
||
|
|
<motion.div
|
||
|
|
className="w-24 h-24 mx-auto mb-2 bg-white dark:bg-gray-800 rounded-xl shadow-lg flex items-center justify-center"
|
||
|
|
whileHover={{ scale: 1.1, rotate: 10 }}
|
||
|
|
transition={{ type: 'spring', stiffness: 300 }}
|
||
|
|
animate={imageLoaded ? {} : { scale: [0.8, 1, 0.8] }}
|
||
|
|
transition={imageLoaded ? {} : { duration: 1.5, repeat: Infinity }}
|
||
|
|
>
|
||
|
|
<span className="text-2xl">🧥</span>
|
||
|
|
</motion.div>
|
||
|
|
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||
|
|
</motion.div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 悬停操作按钮 */}
|
||
|
|
{showActions && (
|
||
|
|
<motion.div
|
||
|
|
initial={{ opacity: 0 }}
|
||
|
|
animate={{ opacity: isHovered ? 1 : 0 }}
|
||
|
|
transition={{ duration: 0.3 }}
|
||
|
|
className="absolute inset-0 bg-gradient-to-br from-black/40 via-black/30 to-transparent flex items-center justify-center"
|
||
|
|
>
|
||
|
|
<div className="flex gap-3">
|
||
|
|
<motion.button
|
||
|
|
variants={getActionButtonVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate={isHovered ? "hover" : "initial"}
|
||
|
|
whileTap="tap"
|
||
|
|
onClick={() => onViewDetails(texture)}
|
||
|
|
className="bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm"
|
||
|
|
title="详细预览"
|
||
|
|
>
|
||
|
|
<motion.div
|
||
|
|
animate={{ scale: [1, 1.1, 1] }}
|
||
|
|
transition={{ duration: 2, repeat: Infinity }}
|
||
|
|
>
|
||
|
|
<EyeIcon className="w-5 h-5" />
|
||
|
|
</motion.div>
|
||
|
|
</motion.button>
|
||
|
|
|
||
|
|
{onDownload !== false && (
|
||
|
|
<motion.button
|
||
|
|
variants={getActionButtonVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate={isHovered ? "hover" : "initial"}
|
||
|
|
whileTap="tap"
|
||
|
|
onClick={handleDownload}
|
||
|
|
disabled={isDownloading}
|
||
|
|
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||
|
|
isDownloading
|
||
|
|
? 'bg-gray-500 cursor-not-allowed'
|
||
|
|
: 'bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white'
|
||
|
|
}`}
|
||
|
|
title={isDownloading ? '下载中...' : '查看原图'}
|
||
|
|
>
|
||
|
|
{isDownloading ? (
|
||
|
|
<motion.div
|
||
|
|
animate={{ rotate: 360 }}
|
||
|
|
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
|
||
|
|
>
|
||
|
|
<ArrowDownTrayIcon className="w-5 h-5" />
|
||
|
|
</motion.div>
|
||
|
|
) : (
|
||
|
|
<ArrowDownTrayIcon className="w-5 h-5" />
|
||
|
|
)}
|
||
|
|
</motion.button>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{onToggleFavorite && (
|
||
|
|
<motion.button
|
||
|
|
variants={getActionButtonVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate={isHovered ? "hover" : "initial"}
|
||
|
|
whileTap="tap"
|
||
|
|
onClick={handleToggleFavorite}
|
||
|
|
disabled={isFavoriting}
|
||
|
|
className={`p-3 rounded-full shadow-lg transition-all duration-200 backdrop-blur-sm ${
|
||
|
|
isFavoriting
|
||
|
|
? 'cursor-not-allowed opacity-75'
|
||
|
|
: isFavorited
|
||
|
|
? 'bg-gradient-to-r from-red-500 to-pink-500 hover:from-red-600 hover:to-pink-600 text-white'
|
||
|
|
: 'bg-gradient-to-r from-gray-500 to-gray-600 hover:from-gray-600 hover:to-gray-700 text-white'
|
||
|
|
}`}
|
||
|
|
title={isFavorited ? '取消收藏' : '添加收藏'}
|
||
|
|
>
|
||
|
|
{isFavoriting ? (
|
||
|
|
<motion.div
|
||
|
|
animate={{ scale: [1, 1.2, 1] }}
|
||
|
|
transition={{ duration: 0.5, repeat: Infinity }}
|
||
|
|
>
|
||
|
|
<HeartIcon className="w-5 h-5" />
|
||
|
|
</motion.div>
|
||
|
|
) : isFavorited ? (
|
||
|
|
<motion.div
|
||
|
|
initial={{ scale: 0 }}
|
||
|
|
animate={{ scale: 1 }}
|
||
|
|
transition={{ type: "spring", stiffness: 300 }}
|
||
|
|
>
|
||
|
|
<HeartIconSolid className="w-5 h-5" />
|
||
|
|
</motion.div>
|
||
|
|
) : (
|
||
|
|
<HeartIcon className="w-5 h-5" />
|
||
|
|
)}
|
||
|
|
</motion.button>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* 标签 */}
|
||
|
|
<div className="absolute top-3 right-3 flex gap-1.5">
|
||
|
|
<motion.span
|
||
|
|
variants={getTagVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate="animate"
|
||
|
|
whileHover="hover"
|
||
|
|
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg ${
|
||
|
|
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||
|
|
}`}
|
||
|
|
>
|
||
|
|
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||
|
|
</motion.span>
|
||
|
|
{texture.is_slim && (
|
||
|
|
<motion.span
|
||
|
|
variants={getTagVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate="animate"
|
||
|
|
whileHover="hover"
|
||
|
|
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||
|
|
>
|
||
|
|
细臂
|
||
|
|
</motion.span>
|
||
|
|
)}
|
||
|
|
{showVisibilityBadge && !texture.is_public && (
|
||
|
|
<motion.span
|
||
|
|
variants={getTagVariants()}
|
||
|
|
initial="initial"
|
||
|
|
animate="animate"
|
||
|
|
whileHover="hover"
|
||
|
|
className="px-2 py-1 bg-gray-800/80 text-white text-xs rounded-full font-medium backdrop-blur-sm shadow-lg"
|
||
|
|
>
|
||
|
|
私密
|
||
|
|
</motion.span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 悬停时的光效 */}
|
||
|
|
<AnimatePresence>
|
||
|
|
{isHovered && (
|
||
|
|
<motion.div
|
||
|
|
initial={{ opacity: 0 }}
|
||
|
|
animate={{ opacity: 1 }}
|
||
|
|
exit={{ opacity: 0 }}
|
||
|
|
className="absolute inset-0 bg-gradient-to-br from-orange-400/10 via-transparent to-amber-400/10 pointer-events-none"
|
||
|
|
style={{
|
||
|
|
background: `radial-gradient(circle at ${50}% ${50}%, rgba(249, 115, 22, 0.1) 0%, transparent 70%)`
|
||
|
|
}}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
</AnimatePresence>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* Texture Info */}
|
||
|
|
<div className="p-4">
|
||
|
|
<motion.h3
|
||
|
|
className="font-semibold text-gray-900 dark:text-white mb-1 truncate"
|
||
|
|
initial={{ opacity: 0, y: 10 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.3 }}
|
||
|
|
>
|
||
|
|
{texture.name}
|
||
|
|
</motion.h3>
|
||
|
|
{texture.description && (
|
||
|
|
<motion.p
|
||
|
|
className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed"
|
||
|
|
initial={{ opacity: 0, y: 10 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.4 }}
|
||
|
|
>
|
||
|
|
{texture.description}
|
||
|
|
</motion.p>
|
||
|
|
)}
|
||
|
|
|
||
|
|
{/* Stats */}
|
||
|
|
<motion.div
|
||
|
|
className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4"
|
||
|
|
initial={{ opacity: 0, y: 10 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.5 }}
|
||
|
|
>
|
||
|
|
<div className="flex items-center space-x-3">
|
||
|
|
{onToggleFavorite && (
|
||
|
|
<motion.span
|
||
|
|
className="flex items-center space-x-1"
|
||
|
|
whileHover={{ scale: 1.05 }}
|
||
|
|
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||
|
|
transition={isFavorited ? { duration: 0.3 } : {}}
|
||
|
|
>
|
||
|
|
<motion.div
|
||
|
|
animate={isFavorited ? { scale: [1, 1.2, 1] } : {}}
|
||
|
|
transition={isFavorited ? { duration: 0.5 } : {}}
|
||
|
|
>
|
||
|
|
<HeartIcon className="w-4 h-4 text-red-400" />
|
||
|
|
</motion.div>
|
||
|
|
<span className="font-medium">{texture.favorite_count || 0}</span>
|
||
|
|
</motion.span>
|
||
|
|
)}
|
||
|
|
<motion.span
|
||
|
|
className="flex items-center space-x-1"
|
||
|
|
whileHover={{ scale: 1.05 }}
|
||
|
|
>
|
||
|
|
<ArrowDownTrayIcon className="w-4 h-4 text-blue-400" />
|
||
|
|
<span className="font-medium">{texture.download_count || 0}</span>
|
||
|
|
</motion.span>
|
||
|
|
</div>
|
||
|
|
<div className="text-xs text-gray-400">
|
||
|
|
{texture.uploader && (
|
||
|
|
<motion.span
|
||
|
|
initial={{ opacity: 0 }}
|
||
|
|
animate={{ opacity: 1 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.6 }}
|
||
|
|
>
|
||
|
|
by {texture.uploader.username}
|
||
|
|
</motion.span>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</motion.div>
|
||
|
|
|
||
|
|
{/* Custom Actions */}
|
||
|
|
{customActions && (
|
||
|
|
<motion.div
|
||
|
|
className="flex gap-2"
|
||
|
|
initial={{ opacity: 0, y: 10 }}
|
||
|
|
animate={{ opacity: 1, y: 0 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.6 }}
|
||
|
|
>
|
||
|
|
{customActions}
|
||
|
|
</motion.div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{/* 底部装饰条 */}
|
||
|
|
<motion.div
|
||
|
|
className="absolute bottom-0 left-0 right-0 h-1 bg-gradient-to-r from-orange-400 to-amber-500"
|
||
|
|
initial={{ scaleX: 0 }}
|
||
|
|
animate={{ scaleX: 1 }}
|
||
|
|
transition={{ delay: index * 0.1 + 0.7, duration: 0.5 }}
|
||
|
|
/>
|
||
|
|
</motion.div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|