Files
carrotskin/src/app/skins/page.tsx

472 lines
21 KiB
TypeScript
Raw Normal View History

'use client';
import { useState, useEffect, useCallback } from 'react';
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';
export default function SkinsPage() {
const [textures, setTextures] = useState<Texture[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const [textureType, setTextureType] = useState<'SKIN' | 'CAPE' | 'ALL'>('ALL');
const [sortBy, setSortBy] = useState('最新');
const [isLoading, setIsLoading] = useState(true);
const [page, setPage] = useState(1);
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 = ['最新', '最热', '最多下载'];
const pageSize = 20;
// 加载材质数据
const loadTextures = useCallback(async () => {
setIsLoading(true);
try {
console.log('开始加载材质数据,参数:', { searchTerm, textureType, sortBy, page, pageSize });
const response = await searchTextures({
keyword: searchTerm || undefined,
type: textureType !== 'ALL' ? textureType : undefined,
public_only: true,
page,
page_size: pageSize,
});
console.log('API响应数据:', response);
console.log('API响应code:', response.code);
console.log('API响应data:', response.data);
if (response.code === 200 && response.data) {
// 安全地处理数据,避免未定义错误
const textureList = response.data.list || [];
const totalCount = response.data.total || 0;
const totalPagesCount = response.data.total_pages || 1;
console.log('解析后的数据:', { textureList, totalCount, totalPagesCount });
console.log('材质列表长度:', textureList.length);
if (textureList.length > 0) {
console.log('第一个材质数据:', textureList[0]);
console.log('第一个材质URL:', textureList[0].url);
}
let sortedList = [...textureList];
// 客户端排序
if (sortedList.length > 0) {
switch (sortBy) {
case '最热':
sortedList = sortedList.sort((a, b) => (b.favorite_count || 0) - (a.favorite_count || 0));
break;
case '最多下载':
sortedList = sortedList.sort((a, b) => (b.download_count || 0) - (a.download_count || 0));
break;
default: // 最新
sortedList = sortedList.sort((a, b) => {
const dateA = new Date(a.created_at || 0).getTime();
const dateB = new Date(b.created_at || 0).getTime();
return dateB - dateA;
});
}
}
setTextures(sortedList);
setTotal(totalCount);
setTotalPages(totalPagesCount);
console.log('设置状态后的数据:', { sortedListLength: sortedList.length, totalCount, totalPagesCount });
} else {
// API返回错误状态
console.warn('API返回错误:', response.message);
console.warn('API完整响应:', response);
setTextures([]);
setTotal(0);
setTotalPages(1);
}
} catch (error) {
console.error('加载材质失败:', error);
// 发生网络或其他错误时,显示空状态
setTextures([]);
setTotal(0);
setTotalPages(1);
} finally {
setIsLoading(false);
console.log('加载完成isLoading设置为false');
}
}, [searchTerm, textureType, sortBy, page]);
// 处理收藏
const handleFavorite = async (textureId: number) => {
if (!isAuthenticated) {
alert('请先登录');
return;
}
try {
const response = await toggleFavorite(textureId);
if (response.code === 200) {
const newFavoritedIds = new Set(favoritedIds);
if (favoritedIds.has(textureId)) {
newFavoritedIds.delete(textureId);
} else {
newFavoritedIds.add(textureId);
}
setFavoritedIds(newFavoritedIds);
}
} catch (error) {
console.error('收藏操作失败:', error);
}
};
// 处理详细预览
const handleDetailView = (texture: Texture) => {
setSelectedTexture(texture);
setIsDetailModalOpen(true);
};
// 关闭详细预览
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 }}
className="text-center mb-12"
>
<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-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.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-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400" />
<input
type="text"
placeholder="搜索皮肤或披风..."
value={searchTerm}
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-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
value={textureType}
onChange={(e) => {
setTextureType(e.target.value as 'SKIN' | 'CAPE' | 'ALL');
setPage(1);
}}
className="w-full pl-10 pr-8 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 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
<option value="ALL"></option>
<option value="SKIN"></option>
<option value="CAPE"></option>
</select>
</div>
</div>
{/* 排序 - 更紧凑 */}
<div className="lg:w-48">
<div className="relative">
<ArrowsUpDownIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={(e) => {
setSortBy(e.target.value);
setPage(1);
}}
className="w-full pl-10 pr-8 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 transition-all duration-200 hover:border-gray-300 dark:hover:border-gray-500 appearance-none"
>
{sortOptions.map(option => (
<option key={option} value={option}>{option}</option>
))}
</select>
</div>
</div>
{/* 搜索按钮 - 更简洁 */}
<motion.button
onClick={loadTextures}
className="px-6 py-2.5 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-medium rounded-xl transition-all duration-200 shadow-md hover:shadow-lg"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
</motion.div>
{/* 结果统计 - 更简洁 */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
className="mb-6 flex justify-between items-center"
>
<p className="text-gray-600 dark:text-gray-400">
<span className="font-semibold text-orange-500">{total}</span>
</p>
{totalPages > 1 && (
<div className="flex gap-2">
<motion.button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === 1 ? 1 : 1.05 }}
whileTap={{ scale: page === 1 ? 1 : 0.95 }}
>
</motion.button>
<span className="px-4 py-2 text-gray-600 dark:text-gray-400">
{page} / {totalPages}
</span>
<motion.button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-50 disabled:cursor-not-allowed hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: page === totalPages ? 1 : 1.05 }}
whileTap={{ scale: page === totalPages ? 1 : 0.95 }}
>
</motion.button>
</div>
)}
</motion.div>
{/* Loading State - 保持但简化 */}
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
{Array.from({ length: 8 }).map((_, i) => (
<motion.div
key={i}
className="animate-pulse"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.1 }}
>
<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>
{/* Results Grid - 更紧凑 */}
<AnimatePresence>
{!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 }}
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.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"
>
{/* 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 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={() => 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>
);
})}
</motion.div>
)}
</AnimatePresence>
{/* Empty State - 简化 */}
<AnimatePresence>
{!isLoading && textures.length === 0 && (
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="text-center py-16"
>
<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>
);
}