Files
carrotskin/src/app/skins/page.tsx
lan 38f11445ba fix(ui): improve authentication UX and refine skin viewer components
- Add loading states to `ProfilePage` and `Navbar` to prevent flickering during authentication checks
- Implement animation pause/resume functionality in `SkinDetailModal` and `SkinViewer`
- Simplify `PageTransition` component by removing unnecessary complexity and state
- Refactor `SkinDetailModal` to use a more robust state management for animations
- Adjust `CharacterCard` skin dimensions for better layout consistency
- Clean up unused imports and redundant animation variants in several components
2026-07-09 17:14:36 +08:00

357 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 SkinDetailModal from '@/components/SkinDetailModal';
import SkinCard from '@/components/SkinCard';
import { searchTextures, toggleFavorite, type Texture } from '@/lib/api';
import { useAuth } from '@/contexts/AuthContext';
import { messageManager } from '@/components/MessageNotification';
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) {
messageManager.warning('请先登录', { duration: 3000 });
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 (
<SkinCard
key={texture.id}
texture={texture}
isFavorited={isFavorited}
onViewDetails={handleDetailView}
onToggleFavorite={isAuthenticated ? handleFavorite : undefined}
onDownload={(texture) => window.open(texture.url, '_blank')}
showVisibilityBadge={false}
/>
);
})}
</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>
);
}