Files
carrotskin/src/app/profile/page.tsx
Wuying Created Local Users 5f90f48a1c feat: 完成navbar隐藏优化和侧边栏冻结功能
- 优化navbar滚动隐藏逻辑,更敏感响应
- 添加返回顶部按钮,固定在右下角
- 实现profile页面侧边栏真正冻结效果
- 修复首页滑动指示器位置
- 优化整体布局确保首屏内容完整显示
2025-12-04 20:05:13 +08:00

1360 lines
66 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 } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import {
UserCircleIcon,
PencilIcon,
TrashIcon,
PlusIcon,
EyeIcon,
ArrowDownTrayIcon,
Cog6ToothIcon,
UserIcon,
PhotoIcon,
KeyIcon,
EnvelopeIcon,
HeartIcon,
ArrowLeftOnRectangleIcon,
CloudArrowUpIcon,
XMarkIcon,
ArrowPathIcon,
XCircleIcon
} from '@heroicons/react/24/outline';
import { useAuth } from '@/contexts/AuthContext';
import {
getMyTextures,
getFavoriteTextures,
toggleFavorite,
getProfiles,
createProfile,
updateProfile,
deleteProfile,
setActiveProfile,
getUserProfile,
updateUserProfile,
uploadTexture,
type Texture,
type Profile
} from '@/lib/api';
interface UserProfile {
id: number;
username: string;
email: string;
avatar?: string;
points: number;
role: string;
status: number;
last_login_at?: string;
created_at: string;
updated_at: string;
}
export default function ProfilePage() {
const [activeTab, setActiveTab] = useState<'characters' | 'skins' | 'favorites' | 'settings'>('characters');
const [profiles, setProfiles] = useState<Profile[]>([]);
const [mySkins, setMySkins] = useState<Texture[]>([]);
const [favoriteSkins, setFavoriteSkins] = useState<Texture[]>([]);
const [userProfile, setUserProfile] = useState<UserProfile | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [showCreateCharacter, setShowCreateCharacter] = useState(false);
const [showUploadSkin, setShowUploadSkin] = useState(false);
const [newCharacterName, setNewCharacterName] = useState('');
const [newSkinData, setNewSkinData] = useState({
name: '',
description: '',
type: 'SKIN' as 'SKIN' | 'CAPE',
is_public: false,
is_slim: false
});
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [editingProfile, setEditingProfile] = useState<string | null>(null);
const [editProfileName, setEditProfileName] = useState('');
const [uploadProgress, setUploadProgress] = useState(0);
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
const [avatarUploadProgress, setAvatarUploadProgress] = useState(0);
const [error, setError] = useState<string | null>(null);
const { user, isAuthenticated, logout } = useAuth();
// 加载用户数据
useEffect(() => {
if (isAuthenticated) {
loadUserData();
}
}, [isAuthenticated]);
const loadUserData = async () => {
setIsLoading(true);
setError(null);
try {
// 加载用户信息
const userResponse = await getUserProfile();
if (userResponse.code === 200) {
setUserProfile(userResponse.data);
} else {
throw new Error(userResponse.message || '获取用户信息失败');
}
// 加载用户档案
const profilesResponse = await getProfiles();
if (profilesResponse.code === 200) {
setProfiles(profilesResponse.data);
} else {
throw new Error(profilesResponse.message || '获取角色列表失败');
}
// 加载用户皮肤
const mySkinsResponse = await getMyTextures({ page: 1, page_size: 50 });
if (mySkinsResponse.code === 200) {
setMySkins(mySkinsResponse.data.list || []);
} else {
throw new Error(mySkinsResponse.message || '获取皮肤列表失败');
}
// 加载收藏的皮肤
const favoritesResponse = await getFavoriteTextures({ page: 1, page_size: 50 });
if (favoritesResponse.code === 200) {
setFavoriteSkins(favoritesResponse.data.list || []);
} else {
throw new Error(favoritesResponse.message || '获取收藏列表失败');
}
} catch (error) {
console.error('加载用户数据失败:', error);
setError(error instanceof Error ? error.message : '加载数据失败,请稍后重试');
} finally {
setIsLoading(false);
}
};
const handleCreateCharacter = async () => {
if (!newCharacterName.trim()) {
alert('请输入角色名称');
return;
}
try {
const response = await createProfile(newCharacterName.trim());
if (response.code === 200) {
setProfiles(prev => [...prev, response.data]);
setNewCharacterName('');
setShowCreateCharacter(false);
alert('角色创建成功!');
} else {
throw new Error(response.message || '创建角色失败');
}
} catch (error) {
console.error('创建角色失败:', error);
alert(error instanceof Error ? error.message : '创建角色失败,请稍后重试');
}
};
const handleDeleteCharacter = async (uuid: string) => {
const character = profiles.find(p => p.uuid === uuid);
if (!character) return;
if (!confirm(`确定要删除角色 "${character.name}" 吗?此操作不可恢复。`)) return;
try {
const response = await deleteProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.filter(profile => profile.uuid !== uuid));
alert('角色删除成功!');
} else {
throw new Error(response.message || '删除角色失败');
}
} catch (error) {
console.error('删除角色失败:', error);
alert(error instanceof Error ? error.message : '删除角色失败,请稍后重试');
}
};
const handleSetActiveCharacter = async (uuid: string) => {
try {
const response = await setActiveProfile(uuid);
if (response.code === 200) {
setProfiles(prev => prev.map(profile => ({
...profile,
is_active: profile.uuid === uuid
})));
alert('角色切换成功!');
} else {
throw new Error(response.message || '设置活跃角色失败');
}
} catch (error) {
console.error('设置活跃角色失败:', error);
alert(error instanceof Error ? error.message : '设置活跃角色失败,请稍后重试');
}
};
const handleEditCharacter = async (uuid: string) => {
if (!editProfileName.trim()) {
alert('请输入角色名称');
return;
}
try {
const response = await updateProfile(uuid, { name: editProfileName.trim() });
if (response.code === 200) {
setProfiles(prev => prev.map(profile =>
profile.uuid === uuid ? response.data : profile
));
setEditingProfile(null);
setEditProfileName('');
alert('角色编辑成功!');
} else {
throw new Error(response.message || '编辑角色失败');
}
} catch (error) {
console.error('编辑角色失败:', error);
alert(error instanceof Error ? error.message : '编辑角色失败,请稍后重试');
}
};
const handleDeleteSkin = async (skinId: number) => {
if (!confirm('确定要删除这个皮肤吗?')) return;
setMySkins(prev => prev.filter(skin => skin.id !== skinId));
};
const handleToggleSkinVisibility = async (skinId: number) => {
try {
const skin = mySkins.find(s => s.id === skinId);
if (!skin) return;
// TODO: 添加更新皮肤API调用
setMySkins(prev => prev.map(skin =>
skin.id === skinId ? { ...skin, is_public: !skin.is_public } : skin
));
} catch (error) {
console.error('切换皮肤可见性失败:', error);
}
};
const handleToggleFavorite = async (skinId: number) => {
try {
const response = await toggleFavorite(skinId);
if (response.code === 200) {
// 重新加载收藏数据
const favoritesResponse = await getFavoriteTextures({ page: 1, page_size: 50 });
if (favoritesResponse.code === 200) {
setFavoriteSkins(favoritesResponse.data.list || []);
}
}
} catch (error) {
console.error('切换收藏状态失败:', error);
}
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
}
};
const handleUploadSkin = async () => {
if (!selectedFile || !newSkinData.name.trim()) {
alert('请选择皮肤文件并输入皮肤名称');
return;
}
setIsUploading(true);
setUploadProgress(0);
try {
// 使用直接上传接口
const progressInterval = setInterval(() => {
setUploadProgress(prev => Math.min(prev + 10, 80));
}, 200);
const response = await uploadTexture(selectedFile, {
name: newSkinData.name.trim(),
description: newSkinData.description.trim(),
type: newSkinData.type,
is_public: newSkinData.is_public,
is_slim: newSkinData.is_slim
});
clearInterval(progressInterval);
setUploadProgress(100);
if (response.code === 200) {
// 重新加载皮肤数据
const mySkinsResponse = await getMyTextures({ page: 1, page_size: 50 });
if (mySkinsResponse.code === 200) {
setMySkins(mySkinsResponse.data.list || []);
}
// 重置表单
setSelectedFile(null);
setNewSkinData({
name: '',
description: '',
type: 'SKIN',
is_public: false,
is_slim: false
});
setShowUploadSkin(false);
alert('皮肤上传成功!');
} else {
throw new Error(response.message || '上传皮肤失败');
}
} catch (error) {
console.error('上传皮肤失败:', error);
alert(error instanceof Error ? error.message : '上传皮肤失败,请稍后重试');
} finally {
setIsUploading(false);
setUploadProgress(0);
}
};
const handleAvatarFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// 验证文件类型和大小
if (!file.type.startsWith('image/')) {
alert('请选择图片文件');
return;
}
if (file.size > 2 * 1024 * 1024) {
alert('文件大小不能超过2MB');
return;
}
setAvatarFile(file);
}
};
const handleUploadAvatar = async () => {
if (!avatarFile) return;
setIsUploadingAvatar(true);
setAvatarUploadProgress(0);
try {
// 获取上传URL
const uploadUrlResponse = await generateAvatarUploadUrl(avatarFile.name);
if (uploadUrlResponse.code !== 200) {
throw new Error(uploadUrlResponse.message || '获取上传URL失败');
}
const { post_url, form_data, avatar_url } = uploadUrlResponse.data;
// 模拟上传进度
const progressInterval = setInterval(() => {
setAvatarUploadProgress(prev => Math.min(prev + 20, 80));
}, 200);
// 上传文件到预签名URL
const formData = new FormData();
Object.entries(form_data).forEach(([key, value]) => {
formData.append(key, value as string);
});
formData.append('file', avatarFile);
const uploadResponse = await fetch(post_url, {
method: 'POST',
body: formData,
});
if (!uploadResponse.ok) {
throw new Error('文件上传失败');
}
clearInterval(progressInterval);
setAvatarUploadProgress(100);
// 更新用户头像URL
const updateResponse = await updateAvatarUrl(avatar_url);
if (updateResponse.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: avatar_url } : null);
alert('头像上传成功!');
} else {
throw new Error(updateResponse.message || '更新头像URL失败');
}
} catch (error) {
console.error('头像上传失败:', error);
alert(error instanceof Error ? error.message : '头像上传失败,请稍后重试');
} finally {
setIsUploadingAvatar(false);
setAvatarUploadProgress(0);
setAvatarFile(null);
}
};
const handleDeleteAvatar = async () => {
if (!confirm('确定要删除头像吗?')) return;
try {
const response = await updateAvatarUrl('');
if (response.code === 200) {
setUserProfile(prev => prev ? { ...prev, avatar: undefined } : null);
alert('头像删除成功!');
} else {
throw new Error(response.message || '删除头像失败');
}
} catch (error) {
console.error('删除头像失败:', error);
alert(error instanceof Error ? error.message : '删除头像失败,请稍后重试');
}
};
const sidebarVariants = {
hidden: { x: -100, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" as const } }
};
const contentVariants = {
hidden: { x: 100, opacity: 0 },
visible: { x: 0, opacity: 1, transition: { duration: 0.5, ease: "easeOut" as const, delay: 0.1 } }
};
if (!isAuthenticated) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ duration: 0.6, type: "spring" }}
className="mb-8"
>
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-3xl flex items-center justify-center shadow-2xl mx-auto">
<UserCircleIcon className="w-12 h-12 text-white" />
</div>
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 text-lg"></p>
</div>
</div>
);
}
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Infinity, ease: "linear" }}
className="mb-8"
>
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 via-amber-500 to-orange-600 rounded-full flex items-center justify-center shadow-xl mx-auto">
<Cog6ToothIcon className="w-8 h-8 text-white" />
</div>
</motion.div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">...</h2>
<p className="text-gray-600 dark:text-gray-400"></p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center 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">
<div className="text-center">
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.6, type: "spring" }}
className="mb-8"
>
<div className="w-24 h-24 bg-gradient-to-br from-red-400 via-red-500 to-red-600 rounded-full flex items-center justify-center shadow-2xl mx-auto">
<XCircleIcon className="w-12 h-12 text-white" />
</div>
</motion.div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4"></h2>
<p className="text-gray-600 dark:text-gray-400 text-lg mb-6">{error}</p>
<motion.button
onClick={loadUserData}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-6 py-3 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200 mx-auto"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<ArrowPathIcon className="w-5 h-5" />
<span></span>
</motion.button>
</div>
</div>
);
}
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/20 to-amber-400/20 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/20 to-orange-400/20 rounded-full blur-3xl animate-pulse delay-1000"></div>
</div>
<div className="relative z-0">
<div className="flex min-h-screen">
{/* Left Sidebar - 完全固定的 */}
<motion.div
variants={sidebarVariants}
initial="hidden"
animate="visible"
className="w-80 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-r border-gray-200/50 dark:border-gray-700/50 p-6 fixed left-0 top-16 h-[calc(100vh-64px)] flex flex-col justify-between"
style={{ position: 'fixed', left: 0, top: '64px', height: 'calc(100vh - 64px)', width: '320px' }}
>
{/* User Profile Card */}
<div className="bg-gradient-to-br from-orange-400 via-orange-500 to-amber-500 rounded-2xl p-6 mb-6 text-white shadow-xl">
<div className="flex items-center space-x-4 mb-4">
{userProfile?.avatar ? (
<img
src={userProfile.avatar}
alt={userProfile.username}
className="w-16 h-16 rounded-full border-3 border-white/30 shadow-lg"
/>
) : (
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center">
<UserCircleIcon className="w-8 h-8 text-white" />
</div>
)}
<div>
<h2 className="text-xl font-bold">{userProfile?.username}</h2>
<p className="text-white/80 text-sm">{userProfile?.email}</p>
</div>
</div>
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-2xl font-bold">{mySkins.length}</div>
<div className="text-white/80 text-xs"></div>
</div>
<div>
<div className="text-2xl font-bold">{favoriteSkins.length}</div>
<div className="text-white/80 text-xs"></div>
</div>
<div>
<div className="text-2xl font-bold">{userProfile?.points || 0}</div>
<div className="text-white/80 text-xs"></div>
</div>
</div>
</div>
{/* Navigation Menu - 固定的 */}
<nav className="flex-1 overflow-y-auto">
<div className="space-y-2">
{[
{ id: 'characters', name: '角色管理', icon: UserIcon, count: profiles.length },
{ id: 'skins', name: '我的皮肤', icon: PhotoIcon, count: mySkins.length },
{ id: 'favorites', name: '收藏夹', icon: HeartIcon, count: favoriteSkins.length },
{ id: 'settings', name: '账户设置', icon: Cog6ToothIcon },
].map((item) => (
<motion.button
key={item.id}
onClick={() => setActiveTab(item.id as any)}
className={`w-full flex items-center justify-between p-3 rounded-xl transition-all duration-200 ${
activeTab === item.id
? 'bg-gradient-to-r from-orange-500 to-amber-500 text-white shadow-lg'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<div className="flex items-center space-x-3">
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.name}</span>
</div>
{item.count !== undefined && (
<span className={`px-2 py-1 rounded-full text-xs ${
activeTab === item.id
? 'bg-white/20 text-white'
: 'bg-gray-200 dark:bg-gray-600 text-gray-600 dark:text-gray-400'
}`}>
{item.count}
</span>
)}
</motion.button>
))}
</div>
</nav>
{/* Logout Button - 始终在底部可见 */}
<motion.button
onClick={logout}
className="w-full flex items-center justify-center space-x-2 p-3 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-xl transition-all duration-200 mt-6"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
<span>退</span>
</motion.button>
</motion.div>
{/* Right Content Area - 考虑左侧fixed侧栏的空间 */}
<motion.div
variants={contentVariants}
initial="hidden"
animate="visible"
className="flex-1 p-8 overflow-y-auto ml-80"
style={{ marginLeft: '320px', minHeight: 'calc(100vh - 64px)' }}
>
<AnimatePresence mode="wait">
{/* Characters Tab */}
{activeTab === 'characters' && (
<motion.div
key="characters"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"></h1>
<motion.button
onClick={() => setShowCreateCharacter(true)}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<PlusIcon className="w-5 h-5" />
<span></span>
</motion.button>
</div>
{/* Create Character Modal */}
<AnimatePresence>
{showCreateCharacter && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-md mx-4"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowCreateCharacter(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={newCharacterName}
onChange={(e) => setNewCharacterName(e.target.value)}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="请输入角色名称"
/>
</div>
<div className="flex space-x-3">
<button
onClick={() => setShowCreateCharacter(false)}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleCreateCharacter}
disabled={!newCharacterName.trim()}
className="flex-1 px-4 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl hover:from-orange-600 hover:to-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{profiles.length === 0 ? (
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
<UserIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2"></h3>
<p className="text-gray-600 dark:text-gray-400">Minecraft角色吧</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{profiles.map((profile) => (
<motion.div
key={profile.uuid}
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }}
>
<div className="flex items-center justify-between mb-4">
{editingProfile === profile.uuid ? (
<input
type="text"
value={editProfileName}
onChange={(e) => setEditProfileName(e.target.value)}
className="text-lg font-semibold bg-transparent border-b border-orange-500 focus:outline-none text-gray-900 dark:text-white"
onBlur={() => handleEditCharacter(profile.uuid)}
onKeyPress={(e) => e.key === 'Enter' && handleEditCharacter(profile.uuid)}
autoFocus
/>
) : (
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">{profile.name}</h3>
)}
{profile.is_active && (
<span className="px-2 py-1 bg-gradient-to-r from-green-500 to-emerald-500 text-white text-xs rounded-full">
使
</span>
)}
</div>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 rounded-xl mb-4 flex items-center justify-center">
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
<div className="flex gap-2">
{!profile.is_active && (
<button
onClick={() => handleSetActiveCharacter(profile.uuid)}
className="flex-1 bg-gradient-to-r from-green-500 to-emerald-500 hover:from-green-600 hover:to-emerald-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200"
>
使
</button>
)}
<button
onClick={() => {
setEditingProfile(profile.uuid);
setEditProfileName(profile.name);
}}
className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 text-sm py-2 px-3 rounded-lg transition-all duration-200"
>
</button>
<button
onClick={() => handleDeleteCharacter(profile.uuid)}
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
)}
{/* Skins Tab */}
{activeTab === 'skins' && (
<motion.div
key="skins"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"></h1>
<motion.button
onClick={() => setShowUploadSkin(true)}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<CloudArrowUpIcon className="w-5 h-5" />
<span></span>
</motion.button>
</div>
{/* Upload Skin Modal */}
<AnimatePresence>
{showUploadSkin && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<motion.div
initial={{ scale: 0.9, y: 20 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.9, y: 20 }}
className="bg-white dark:bg-gray-800 rounded-2xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto"
>
<div className="flex justify-between items-center mb-4">
<h3 className="text-xl font-bold text-gray-900 dark:text-white"></h3>
<button
onClick={() => setShowUploadSkin(false)}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<XMarkIcon className="w-6 h-6" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<div className="border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-xl p-6 text-center hover:border-orange-500 transition-colors">
<CloudArrowUpIcon className="w-12 h-12 text-gray-400 mx-auto mb-2" />
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
</p>
<input
type="file"
accept=".png"
onChange={handleFileSelect}
className="hidden"
id="skin-upload"
/>
<label
htmlFor="skin-upload"
className="cursor-pointer bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-lg hover:from-orange-600 hover:to-amber-600 transition-all"
>
</label>
{selectedFile && (
<p className="text-sm text-green-600 dark:text-green-400 mt-2">
: {selectedFile.name}
</p>
)}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={newSkinData.name}
onChange={(e) => setNewSkinData(prev => ({ ...prev, name: e.target.value }))}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="请输入皮肤名称"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<textarea
value={newSkinData.description}
onChange={(e) => setNewSkinData(prev => ({ ...prev, description: e.target.value }))}
className="w-full px-4 py-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent"
placeholder="请输入皮肤描述(可选)"
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newSkinData.is_public}
onChange={(e) => setNewSkinData(prev => ({ ...prev, is_public: e.target.checked }))}
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300"></span>
</label>
</div>
<div>
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newSkinData.is_slim}
onChange={(e) => setNewSkinData(prev => ({ ...prev, is_slim: e.target.checked }))}
className="w-4 h-4 text-orange-500 rounded focus:ring-orange-500"
/>
<span className="text-sm text-gray-700 dark:text-gray-300"></span>
</label>
</div>
</div>
{isUploading && (
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-orange-500 to-amber-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
)}
<div className="flex space-x-3">
<button
onClick={() => setShowUploadSkin(false)}
className="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
</button>
<button
onClick={handleUploadSkin}
disabled={!selectedFile || !newSkinData.name.trim() || isUploading}
className="flex-1 px-4 py-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-xl hover:from-orange-600 hover:to-amber-600 disabled:opacity-50 disabled:cursor-not-allowed transition-all"
>
{isUploading ? `上传中... ${uploadProgress}%` : '上传'}
</button>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
{mySkins.length === 0 ? (
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
<PhotoIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2"></h3>
<p className="text-gray-600 dark:text-gray-400">Minecraft皮肤吧</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{mySkins.map((skin) => (
<motion.div
key={skin.id}
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl overflow-hidden border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }}
>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
{!skin.is_public && (
<div className="absolute top-3 right-3 px-2 py-1 bg-gray-800/80 text-white text-xs rounded-full backdrop-blur-sm">
</div>
)}
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{skin.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{new Date(skin.created_at).toLocaleDateString()}
</p>
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
<span className="flex items-center space-x-1">
<ArrowDownTrayIcon className="w-4 h-4" />
<span>{skin.download_count}</span>
</span>
<span className="flex items-center space-x-1">
<EyeIcon className="w-4 h-4" />
<span>{skin.favorite_count}</span>
</span>
</div>
<div className="flex gap-2">
<button className="flex-1 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200">
</button>
<button
onClick={() => handleToggleSkinVisibility(skin.id)}
className="px-3 py-2 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-all duration-200"
>
{skin.is_public ? '隐藏' : '公开'}
</button>
<button
onClick={() => handleDeleteSkin(skin.id)}
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
)}
{/* Favorites Tab */}
{activeTab === 'favorites' && (
<motion.div
key="favorites"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<div className="flex justify-between items-center">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"></h1>
</div>
{favoriteSkins.length === 0 ? (
<div className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-12 text-center">
<HeartIcon className="w-16 h-16 text-gray-400 mx-auto mb-4" />
<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>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{favoriteSkins.map((skin) => (
<motion.div
key={skin.id}
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl overflow-hidden border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ scale: 1.02, y: -5 }}
transition={{ duration: 0.2 }}
>
<div className="aspect-square bg-gradient-to-br from-orange-100 to-amber-100 dark:from-gray-700 dark:to-gray-600 relative">
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-24 h-24 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg shadow-lg"></div>
</div>
<div className="absolute top-3 right-3 px-2 py-1 bg-red-500/80 text-white text-xs rounded-full backdrop-blur-sm flex items-center space-x-1">
<HeartIcon className="w-3 h-3" />
<span></span>
</div>
</div>
<div className="p-4">
<h3 className="font-semibold text-gray-900 dark:text-white mb-1">{skin.name}</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{skin.uploader_id}
</p>
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400 mb-4">
<span className="flex items-center space-x-1">
<ArrowDownTrayIcon className="w-4 h-4" />
<span>{skin.download_count}</span>
</span>
<span className="flex items-center space-x-1">
<EyeIcon className="w-4 h-4" />
<span>{skin.favorite_count}</span>
</span>
</div>
<div className="flex gap-2">
<button className="flex-1 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white text-sm py-2 px-3 rounded-lg transition-all duration-200">
</button>
<button
onClick={() => handleToggleFavorite(skin.id)}
className="px-3 py-2 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-lg transition-all duration-200"
>
</button>
</div>
</div>
</motion.div>
))}
</div>
)}
</motion.div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<motion.div
key="settings"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6"
>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"></h1>
<div className="space-y-6">
{/* Avatar Settings */}
<motion.div
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<PhotoIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="flex items-center space-x-6">
<div className="relative">
{userProfile?.avatar ? (
<img
src={userProfile.avatar}
alt={userProfile.username}
className="w-20 h-20 rounded-full border-4 border-orange-500 shadow-lg"
/>
) : (
<div className="w-20 h-20 bg-gradient-to-br from-orange-400 to-amber-500 rounded-full flex items-center justify-center shadow-lg">
<UserCircleIcon className="w-10 h-10 text-white" />
</div>
)}
<motion.button
className="absolute -bottom-2 -right-2 bg-gradient-to-r from-orange-500 to-amber-500 text-white p-2 rounded-full shadow-lg"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
onClick={() => document.getElementById('avatar-upload')?.click()}
>
<PencilIcon className="w-4 h-4" />
</motion.button>
<input
type="file"
id="avatar-upload"
accept="image/*"
onChange={handleAvatarFileSelect}
className="hidden"
/>
</div>
<div className="flex-1">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
JPGPNG 2MB
</p>
{avatarFile && (
<div className="mb-3">
<p className="text-sm text-green-600 dark:text-green-400">
: {avatarFile.name}
</p>
</div>
)}
{isUploadingAvatar && (
<div className="mb-3">
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-gradient-to-r from-orange-500 to-amber-500 h-2 rounded-full transition-all duration-300"
style={{ width: `${avatarUploadProgress}%` }}
/>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
... {avatarUploadProgress}%
</p>
</div>
)}
<div className="flex space-x-3">
<motion.button
onClick={handleUploadAvatar}
disabled={!avatarFile || isUploadingAvatar}
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<CloudArrowUpIcon className="w-4 h-4" />
<span>{isUploadingAvatar ? '上传中...' : '上传头像'}</span>
</motion.button>
{userProfile?.avatar && (
<motion.button
onClick={handleDeleteAvatar}
disabled={isUploadingAvatar}
className="border border-red-500 text-red-500 hover:bg-red-500 hover:text-white px-4 py-2 rounded-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
)}
</div>
</div>
</div>
</motion.div>
{/* Basic Info */}
<motion.div
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<UserIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile?.username || ''}
readOnly
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="email"
value={userProfile?.email || ''}
readOnly
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile ? new Date(userProfile.created_at).toLocaleDateString() : ''}
readOnly
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="text"
value={userProfile?.last_login_at ? new Date(userProfile.last_login_at).toLocaleString() : '从未登录'}
readOnly
className="w-full px-4 py-3 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-xl text-gray-500 dark:text-gray-400"
/>
</div>
</div>
<div className="mt-4">
<motion.button
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<PencilIcon className="w-4 h-4" />
<span></span>
</motion.button>
</div>
</motion.div>
{/* Password Settings */}
<motion.div
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<KeyIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="password"
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
placeholder="请输入当前密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="password"
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
placeholder="请输入新密码"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
</label>
<input
type="password"
className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
placeholder="请确认新密码"
/>
</div>
</div>
<motion.button
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl flex items-center space-x-2 shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
<KeyIcon className="w-4 h-4" />
<span></span>
</motion.button>
</div>
</motion.div>
{/* API Settings */}
<motion.div
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<KeyIcon className="w-5 h-5" />
<span>API设置</span>
</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Yggdrasil API密钥
</label>
<div className="flex gap-2">
<input
type="password"
value="your-api-key-here"
readOnly
className="flex-1 px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200"
/>
<motion.button
className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200"
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
>
</motion.button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Minecraft客户端连接
</p>
</div>
</div>
</motion.div>
{/* Account Actions */}
<motion.div
className="bg-white/50 dark:bg-gray-800/50 backdrop-blur-lg rounded-2xl p-6 border border-white/20 dark:border-gray-700/50 shadow-lg"
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-4 flex items-center space-x-2">
<Cog6ToothIcon className="w-5 h-5" />
<span></span>
</h3>
<div className="space-y-3">
<motion.button
className="w-full flex items-center justify-between p-3 border border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white rounded-xl transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span></span>
<EnvelopeIcon className="w-5 h-5" />
</motion.button>
<motion.button
onClick={logout}
className="w-full flex items-center justify-between p-3 border border-red-500 text-red-500 hover:bg-red-500 hover:text-white rounded-xl transition-all duration-200"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<span>退</span>
<ArrowLeftOnRectangleIcon className="w-5 h-5" />
</motion.button>
</div>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</div>
</div>
</div>
);
}