feat: 增加用户皮肤管理功能和Yggdrasil密码重置
- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤 - 实现Yggdrasil密码重置功能,用户可生成新密码并显示 - 优化皮肤展示和选择界面,增强用户体验 - 更新SkinViewer组件,支持跑步和跳跃动画
This commit is contained in:
@@ -5,6 +5,7 @@ import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { MagnifyingGlassIcon, EyeIcon, HeartIcon, ArrowDownTrayIcon, SparklesIcon, FunnelIcon, ArrowsUpDownIcon } from '@heroicons/react/24/outline';
|
||||
import { HeartIcon as HeartIconSolid } from '@heroicons/react/24/solid';
|
||||
import SkinViewer from '@/components/SkinViewer';
|
||||
import SkinDetailModal from '@/components/SkinDetailModal';
|
||||
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
|
||||
@@ -18,6 +19,8 @@ export default function SkinsPage() {
|
||||
const [total, setTotal] = useState(0);
|
||||
const [totalPages, setTotalPages] = useState(1);
|
||||
const [favoritedIds, setFavoritedIds] = useState<Set<number>>(new Set());
|
||||
const [selectedTexture, setSelectedTexture] = useState<Texture | null>(null);
|
||||
const [isDetailModalOpen, setIsDetailModalOpen] = useState(false);
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
const sortOptions = ['最新', '最热', '最多下载'];
|
||||
@@ -100,103 +103,88 @@ export default function SkinsPage() {
|
||||
}
|
||||
}, [searchTerm, textureType, sortBy, page]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTextures();
|
||||
}, [loadTextures]);
|
||||
|
||||
// 处理收藏
|
||||
const handleFavorite = async (id: number) => {
|
||||
const handleFavorite = async (textureId: number) => {
|
||||
if (!isAuthenticated) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await toggleFavorite(id);
|
||||
const response = await toggleFavorite(textureId);
|
||||
if (response.code === 200) {
|
||||
setFavoritedIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (response.data.is_favorited) {
|
||||
newSet.add(id);
|
||||
} else {
|
||||
newSet.delete(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
// 更新本地数据
|
||||
setTextures(prev => prev.map(texture =>
|
||||
texture.id === id
|
||||
? {
|
||||
...texture,
|
||||
favorite_count: response.data.is_favorited
|
||||
? texture.favorite_count + 1
|
||||
: Math.max(0, texture.favorite_count - 1)
|
||||
}
|
||||
: texture
|
||||
));
|
||||
const newFavoritedIds = new Set(favoritedIds);
|
||||
if (favoritedIds.has(textureId)) {
|
||||
newFavoritedIds.delete(textureId);
|
||||
} else {
|
||||
newFavoritedIds.add(textureId);
|
||||
}
|
||||
setFavoritedIds(newFavoritedIds);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('收藏操作失败:', error);
|
||||
alert('操作失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-50 via-orange-50 to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
{/* Animated Background - 保持背景但简化 */}
|
||||
<div className="fixed inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute -top-40 -right-40 w-80 h-80 bg-gradient-to-br from-orange-400/10 to-amber-400/10 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute -bottom-40 -left-40 w-80 h-80 bg-gradient-to-tr from-pink-400/10 to-orange-400/10 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
// 处理详细预览
|
||||
const handleDetailView = (texture: Texture) => {
|
||||
setSelectedTexture(texture);
|
||||
setIsDetailModalOpen(true);
|
||||
};
|
||||
|
||||
<div className="relative z-0 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* 简化的头部区域 */}
|
||||
// 关闭详细预览
|
||||
const handleCloseDetailModal = () => {
|
||||
setIsDetailModalOpen(false);
|
||||
setSelectedTexture(null);
|
||||
};
|
||||
|
||||
// 初始化和搜索
|
||||
useEffect(() => {
|
||||
loadTextures();
|
||||
}, [loadTextures]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-orange-50/30 via-amber-50/20 to-yellow-50/30 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
className="mb-8"
|
||||
transition={{ duration: 0.6 }}
|
||||
className="text-center mb-12"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
|
||||
皮肤库
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
发现和分享精彩的Minecraft皮肤与披风
|
||||
<p className="text-lg text-gray-600 dark:text-gray-300 max-w-2xl mx-auto">
|
||||
发现和分享精美的 Minecraft 皮肤和披风,支持 3D 预览和动画效果
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 重新设计的搜索区域 - 更紧凑专业 */}
|
||||
{/* Search and Filter Section - 更紧凑的设计 */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.5 }}
|
||||
className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg p-6 mb-6 border border-white/10 dark:border-gray-700/30"
|
||||
transition={{ delay: 0.2, duration: 0.6 }}
|
||||
className="mb-8"
|
||||
>
|
||||
<div className="flex flex-col lg:flex-row gap-4 items-end">
|
||||
{/* 搜索框 - 更紧凑 */}
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<MagnifyingGlassIcon className="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索皮肤、披风或作者..."
|
||||
placeholder="搜索皮肤或披风..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => {
|
||||
setSearchTerm(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
loadTextures();
|
||||
}
|
||||
}}
|
||||
className="w-full pl-10 pr-4 py-2.5 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && loadTextures()}
|
||||
className="w-full pl-12 pr-4 py-3 border border-gray-200 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-orange-500 bg-white/80 dark:bg-gray-700/80 text-gray-900 dark:text-white transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 - 更紧凑 */}
|
||||
<div className="lg:w-48">
|
||||
<div className="lg:w-40">
|
||||
<div className="relative">
|
||||
<FunnelIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
@@ -299,137 +287,149 @@ export default function SkinsPage() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: i * 0.1 }}
|
||||
>
|
||||
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl aspect-square mb-3"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"></div>
|
||||
<div className="h-3 bg-gray-200 dark:bg-gray-700 rounded w-2/3"></div>
|
||||
<div className="bg-gray-200 dark:bg-gray-700 rounded-xl h-64 mb-4"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mb-2"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2"></div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Textures Grid - 保持卡片设计但简化 */}
|
||||
{/* Results Grid - 更紧凑 */}
|
||||
<AnimatePresence>
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
{!isLoading && textures.length > 0 && (
|
||||
<motion.div
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{textures.map((texture, index) => {
|
||||
const isFavorited = favoritedIds.has(texture.id);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
key={texture.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
className="group relative"
|
||||
transition={{ delay: index * 0.1 }}
|
||||
className="group relative bg-white/80 dark:bg-gray-800/80 backdrop-blur-sm rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 overflow-hidden border border-white/20 dark:border-gray-700/30"
|
||||
>
|
||||
<div className="bg-white/60 dark:bg-gray-800/60 backdrop-blur-md rounded-2xl shadow-lg hover:shadow-xl transition-all duration-300 border border-white/10 dark:border-gray-700/30 overflow-hidden">
|
||||
{/* 3D Skin Preview */}
|
||||
<div className="aspect-square bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 relative overflow-hidden group flex items-center justify-center">
|
||||
{texture.type === 'SKIN' ? (
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={400}
|
||||
height={400}
|
||||
className="w-full h-full transition-transform duration-300 group-hover:scale-105"
|
||||
autoRotate={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<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: 5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<span className="text-xl">🧥</span>
|
||||
</motion.div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 font-medium">披风</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||
{/* 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">
|
||||
<SkinViewer
|
||||
skinUrl={texture.url}
|
||||
isSlim={texture.is_slim}
|
||||
width={300}
|
||||
height={300}
|
||||
className="w-full h-full"
|
||||
autoRotate={true}
|
||||
walking={false}
|
||||
/>
|
||||
|
||||
{/* 悬停操作按钮 */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||
<div className="flex gap-3">
|
||||
<motion.button
|
||||
onClick={() => handleDetailView(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"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="详细预览"
|
||||
>
|
||||
<EyeIcon className="w-5 h-5" />
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
onClick={() => window.open(texture.url, '_blank')}
|
||||
className="bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white p-3 rounded-full shadow-lg transition-all duration-200"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
title="查看原图"
|
||||
>
|
||||
<ArrowDownTrayIcon className="w-5 h-5" />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||
<motion.span
|
||||
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
|
||||
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
</motion.span>
|
||||
{texture.is_slim && (
|
||||
<motion.span
|
||||
className={`px-2 py-1 text-white text-xs rounded-full font-medium backdrop-blur-sm ${
|
||||
texture.type === 'SKIN' ? 'bg-blue-500/80' : 'bg-purple-500/80'
|
||||
}`}
|
||||
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
{texture.type === 'SKIN' ? '皮肤' : '披风'}
|
||||
细臂
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Texture Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
|
||||
{texture.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
|
||||
{texture.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||
<span className="font-medium">{texture.favorite_count}</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}</span>
|
||||
</motion.span>
|
||||
{texture.is_slim && (
|
||||
<motion.span
|
||||
className="px-2 py-1 bg-pink-500/80 text-white text-xs rounded-full font-medium backdrop-blur-sm"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
细臂
|
||||
</motion.span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Texture Info */}
|
||||
<div className="p-4">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white mb-1 truncate">{texture.name}</h3>
|
||||
{texture.description && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2 leading-relaxed">
|
||||
{texture.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<div className="flex items-center space-x-3">
|
||||
<motion.span
|
||||
className="flex items-center space-x-1"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
>
|
||||
<HeartIcon className="w-4 h-4 text-red-400" />
|
||||
<span className="font-medium">{texture.favorite_count}</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}</span>
|
||||
</motion.span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
onClick={() => window.open(texture.url, '_blank')}
|
||||
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
查看
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => handleFavorite(texture.id)}
|
||||
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
|
||||
isFavorited
|
||||
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
|
||||
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<HeartIconSolid className="w-4 h-4" />
|
||||
) : (
|
||||
<HeartIcon className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
{/* Action Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
onClick={() => handleDetailView(texture)}
|
||||
className="flex-1 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200 font-medium shadow-md hover:shadow-lg flex items-center justify-center"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<EyeIcon className="w-4 h-4 mr-1" />
|
||||
详细预览
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={() => handleFavorite(texture.id)}
|
||||
className={`px-3 py-2 border rounded-lg transition-all duration-200 font-medium ${
|
||||
isFavorited
|
||||
? 'bg-gradient-to-r from-red-500 to-pink-500 border-transparent text-white shadow-md'
|
||||
: 'border-orange-500 text-orange-500 hover:bg-gradient-to-r hover:from-orange-500 hover:to-orange-600 hover:text-white hover:border-transparent hover:shadow-md'
|
||||
}`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isFavorited ? (
|
||||
<HeartIconSolid className="w-4 h-4" />
|
||||
) : (
|
||||
<HeartIcon className="w-4 h-4" />
|
||||
)}
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
@@ -448,15 +448,24 @@ export default function SkinsPage() {
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="text-center py-16"
|
||||
>
|
||||
<div className="w-20 h-20 bg-gray-200 dark:bg-gray-700 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<MagnifyingGlassIcon className="w-10 h-10 text-gray-400" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-2">没有找到相关材质</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">尝试调整搜索条件</p>
|
||||
<div className="text-6xl mb-4">🎨</div>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
暂无材质
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
没有找到符合条件的皮肤或披风,试试其他搜索条件吧
|
||||
</p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* 详细预览对话框 */}
|
||||
<SkinDetailModal
|
||||
isOpen={isDetailModalOpen}
|
||||
onClose={handleCloseDetailModal}
|
||||
texture={selectedTexture}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user