feat: 完成navbar隐藏优化和侧边栏冻结功能
- 优化navbar滚动隐藏逻辑,更敏感响应 - 添加返回顶部按钮,固定在右下角 - 实现profile页面侧边栏真正冻结效果 - 修复首页滑动指示器位置 - 优化整体布局确保首屏内容完整显示
This commit is contained in:
11
src/app/auth/layout.tsx
Normal file
11
src/app/auth/layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
export default function AuthLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
732
src/app/auth/page.tsx
Normal file
732
src/app/auth/page.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { errorManager } from '@/components/ErrorNotification';
|
||||
|
||||
export default function AuthPage() {
|
||||
const [isLoginMode, setIsLoginMode] = useState(true);
|
||||
const [formData, setFormData] = useState({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
verificationCode: '',
|
||||
rememberMe: false,
|
||||
agreeToTerms: false
|
||||
});
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [authError, setAuthError] = useState('');
|
||||
const [isSendingCode, setIsSendingCode] = useState(false);
|
||||
const [codeTimer, setCodeTimer] = useState(0);
|
||||
|
||||
const { login, register } = useAuth();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout;
|
||||
if (codeTimer > 0) {
|
||||
interval = setInterval(() => {
|
||||
setCodeTimer(prev => prev - 1);
|
||||
}, 1000);
|
||||
}
|
||||
return () => clearInterval(interval);
|
||||
}, [codeTimer]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value, type, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'checkbox' ? checked : value
|
||||
}));
|
||||
|
||||
if (errors[name]) {
|
||||
setErrors(prev => ({ ...prev, [name]: '' }));
|
||||
}
|
||||
if (authError) {
|
||||
setAuthError('');
|
||||
}
|
||||
};
|
||||
|
||||
const validateLoginForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = '请输入用户名或邮箱';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = '请输入密码';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const validateRegisterForm = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.username.trim()) {
|
||||
newErrors.username = '用户名不能为空';
|
||||
} else if (formData.username.length < 3) {
|
||||
newErrors.username = '用户名至少需要3个字符';
|
||||
} else if (formData.username.length > 50) {
|
||||
newErrors.username = '用户名不能超过50个字符';
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = '邮箱不能为空';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = '请输入有效的邮箱地址';
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = '密码不能为空';
|
||||
} else if (formData.password.length < 6) {
|
||||
newErrors.password = '密码至少需要6个字符';
|
||||
} else if (formData.password.length > 128) {
|
||||
newErrors.password = '密码不能超过128个字符';
|
||||
}
|
||||
|
||||
if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = '两次输入的密码不一致';
|
||||
}
|
||||
|
||||
if (!formData.verificationCode) {
|
||||
newErrors.verificationCode = '请输入验证码';
|
||||
} else if (!/^\d{6}$/.test(formData.verificationCode)) {
|
||||
newErrors.verificationCode = '验证码应为6位数字';
|
||||
}
|
||||
|
||||
if (!formData.agreeToTerms) {
|
||||
newErrors.agreeToTerms = '请同意服务条款';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const getPasswordStrength = () => {
|
||||
const password = formData.password;
|
||||
if (password.length === 0) return { strength: 0, label: '', color: 'bg-gray-200' };
|
||||
if (password.length < 6) return { strength: 1, label: '弱', color: 'bg-red-500' };
|
||||
if (password.length < 10) return { strength: 2, label: '中等', color: 'bg-yellow-500' };
|
||||
if (password.length >= 15) return { strength: 4, label: '很强', color: 'bg-green-500' };
|
||||
return { strength: 3, label: '强', color: 'bg-blue-500' };
|
||||
};
|
||||
|
||||
const passwordStrength = getPasswordStrength();
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' }));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSendingCode(true);
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/send-code', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: formData.email,
|
||||
type: 'register'
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.code === 200) {
|
||||
setCodeTimer(60);
|
||||
errorManager.showSuccess('验证码已发送到您的邮箱');
|
||||
} else {
|
||||
setErrors(prev => ({ ...prev, email: data.message || '发送验证码失败' }));
|
||||
errorManager.showError(data.message || '发送验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
setErrors(prev => ({ ...prev, email: '发送验证码失败,请稍后重试' }));
|
||||
errorManager.showError('发送验证码失败,请稍后重试');
|
||||
} finally {
|
||||
setIsSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (isLoginMode) {
|
||||
if (!validateLoginForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
await login(formData.username, formData.password);
|
||||
|
||||
if (formData.rememberMe) {
|
||||
localStorage.setItem('rememberMe', 'true');
|
||||
} else {
|
||||
localStorage.removeItem('rememberMe');
|
||||
}
|
||||
|
||||
errorManager.showSuccess('登录成功!');
|
||||
router.push('/');
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '登录失败,请检查用户名和密码';
|
||||
setAuthError(errorMessage);
|
||||
errorManager.showError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} else {
|
||||
if (!validateRegisterForm()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setAuthError('');
|
||||
|
||||
try {
|
||||
await register(formData.username, formData.email, formData.password, formData.verificationCode);
|
||||
errorManager.showSuccess('注册成功!欢迎加入CarrotSkin!');
|
||||
router.push('/');
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '注册失败,请稍后重试';
|
||||
setAuthError(errorMessage);
|
||||
errorManager.showError(errorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const switchMode = () => {
|
||||
setIsLoginMode(!isLoginMode);
|
||||
setAuthError('');
|
||||
setErrors({});
|
||||
setFormData({
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
verificationCode: '',
|
||||
rememberMe: false,
|
||||
agreeToTerms: false
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
{/* Left Side - Orange Section */}
|
||||
<div className="hidden lg:flex lg:w-1/2 bg-gradient-to-br from-orange-500 via-orange-400 to-amber-500 flex-col justify-center items-center text-white p-12">
|
||||
<motion.div
|
||||
className="max-w-md"
|
||||
initial={{ opacity: 0, x: -50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
<motion.div
|
||||
className="w-24 h-24 bg-white/20 rounded-2xl flex items-center justify-center mb-8 backdrop-blur-sm border border-white/30"
|
||||
whileHover={{ scale: 1.05, rotate: 5 }}
|
||||
transition={{ type: 'spring', stiffness: 300 }}
|
||||
>
|
||||
<span className="text-4xl font-black">CS</span>
|
||||
</motion.div>
|
||||
|
||||
<motion.h1
|
||||
className="text-5xl font-black mb-6 leading-tight"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.6 }}
|
||||
>
|
||||
{isLoginMode ? '欢迎回来' : '加入我们'}
|
||||
</motion.h1>
|
||||
|
||||
<motion.p
|
||||
className="text-2xl mb-12 text-white/90 leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.6 }}
|
||||
>
|
||||
{isLoginMode ? '继续你的创作之旅' : '开始你的创作之旅'}
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="space-y-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-medium">创建独特的皮肤设计</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-medium">与社区分享作品</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-8 h-8 bg-white/25 rounded-full flex items-center justify-center flex-shrink-0">
|
||||
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-xl font-medium">发现精彩创意</span>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
className="mt-16"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<p className="text-lg text-white/80">
|
||||
已有超过 <span className="font-bold text-white text-2xl">10,000</span> 位创作者加入我们
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Right Side - White Section */}
|
||||
<div className="w-full lg:w-1/2 bg-white dark:bg-gray-900 flex flex-col justify-center items-center p-4 lg:p-8">
|
||||
<motion.div
|
||||
className="w-full max-w-md"
|
||||
initial={{ opacity: 0, x: 50 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
{/* Back to Home Button */}
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.6 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center text-gray-500 dark:text-gray-400 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200"
|
||||
>
|
||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
返回主页
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Header */}
|
||||
<motion.div
|
||||
className="mb-6"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2, duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-4xl font-black text-gray-900 dark:text-white mb-2">
|
||||
{isLoginMode ? '登录账户' : '创建账户'}
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
{isLoginMode ? '登录您的CarrotSkin账户' : '加入我们,开始创作'}
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{authError && (
|
||||
<motion.div
|
||||
className="mb-4 p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-xl"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<p className="text-red-600 dark:text-red-400 text-sm">{authError}</p>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Username */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3 }}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{isLoginMode ? '用户名或邮箱' : '用户名'}
|
||||
{!isLoginMode && ' (3-50个字符)'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||
errors.username ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder={isLoginMode ? "请输入用户名或邮箱" : "请输入用户名"}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{!isLoginMode && formData.username && !errors.username && (
|
||||
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
|
||||
)}
|
||||
{errors.username && (
|
||||
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
{errors.username && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.username}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Email - Only for registration */}
|
||||
<AnimatePresence>
|
||||
{!isLoginMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||
errors.email ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入邮箱地址"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
{formData.email && !errors.email && (
|
||||
<CheckCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-green-500" />
|
||||
)}
|
||||
{errors.email && (
|
||||
<XCircleIcon className="absolute right-3 top-3.5 w-5 h-5 text-red-500" />
|
||||
)}
|
||||
</div>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Password */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
密码
|
||||
{!isLoginMode && ' (6-128个字符)'}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
|
||||
errors.password ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入密码"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{!isLoginMode && formData.password && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-sm mb-1">
|
||||
<span className="text-gray-600 dark:text-gray-400">密码强度</span>
|
||||
<span className={`font-medium ${passwordStrength.color.replace('bg-', 'text-')}`}>
|
||||
{passwordStrength.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all duration-300 ${passwordStrength.color}`}
|
||||
style={{ width: `${passwordStrength.strength * 25}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.password}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Confirm Password - Only for registration */}
|
||||
<AnimatePresence>
|
||||
{!isLoginMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
确认密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent pr-12 focus:bg-white dark:focus:bg-gray-700 ${
|
||||
errors.confirmPassword ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请再次输入密码"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-3.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
|
||||
>
|
||||
{showConfirmPassword ? <EyeSlashIcon className="w-5 h-5" /> : <EyeIcon className="w-5 h-5" />}
|
||||
</button>
|
||||
</div>
|
||||
{formData.confirmPassword && formData.password === formData.confirmPassword && !errors.confirmPassword && (
|
||||
<CheckCircleIcon className="mt-1 w-5 h-5 text-green-500" />
|
||||
)}
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.confirmPassword}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Verification Code - Only for registration */}
|
||||
<AnimatePresence>
|
||||
{!isLoginMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
邮箱验证码
|
||||
</label>
|
||||
<div className="flex space-x-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
type="text"
|
||||
name="verificationCode"
|
||||
value={formData.verificationCode}
|
||||
onChange={handleInputChange}
|
||||
className={`w-full px-4 py-3 bg-gray-50 dark:bg-gray-800 border-2 rounded-xl transition-all duration-200 focus:ring-2 focus:ring-orange-500 focus:border-transparent focus:bg-white dark:focus:bg-gray-700 ${
|
||||
errors.verificationCode ? 'border-red-500' : 'border-gray-200 dark:border-gray-600'
|
||||
}`}
|
||||
placeholder="请输入6位验证码"
|
||||
disabled={isLoading}
|
||||
maxLength={6}
|
||||
/>
|
||||
{errors.verificationCode && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.verificationCode}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendCode}
|
||||
disabled={isSendingCode || codeTimer > 0 || !formData.email || !!errors.email}
|
||||
className="px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-medium rounded-xl transition-all duration-200 whitespace-nowrap"
|
||||
>
|
||||
{codeTimer > 0 ? `${codeTimer}秒后重试` : (isSendingCode ? '发送中...' : '发送验证码')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Remember Me - Only for login */}
|
||||
<AnimatePresence>
|
||||
{isLoginMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex items-center justify-between"
|
||||
>
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="rememberMe"
|
||||
checked={formData.rememberMe}
|
||||
onChange={handleInputChange}
|
||||
className="w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
记住我
|
||||
</span>
|
||||
</label>
|
||||
<Link href="/forgot-password" className="text-sm text-orange-500 hover:text-orange-600 transition-colors">
|
||||
忘记密码?
|
||||
</Link>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Terms Agreement - Only for registration */}
|
||||
<AnimatePresence>
|
||||
{!isLoginMode && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<label className="flex items-start space-x-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="agreeToTerms"
|
||||
checked={formData.agreeToTerms}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 w-4 h-4 text-orange-500 border-2 border-gray-300 rounded focus:ring-orange-500 focus:ring-2"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<span className="text-sm text-gray-600 dark:text-gray-400">
|
||||
我已阅读并同意
|
||||
<Link href="/terms" className="text-orange-500 hover:text-orange-600 underline ml-1">
|
||||
服务条款
|
||||
</Link>
|
||||
和
|
||||
<Link href="/privacy" className="text-orange-500 hover:text-orange-600 underline ml-1">
|
||||
隐私政策
|
||||
</Link>
|
||||
</span>
|
||||
</label>
|
||||
{errors.agreeToTerms && (
|
||||
<p className="mt-1 text-sm text-red-500">{errors.agreeToTerms}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Submit Button */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<motion.button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 disabled:from-gray-400 disabled:to-gray-500 text-white font-semibold py-4 px-6 rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
<span>{isLoginMode ? '登录中...' : '注册中...'}</span>
|
||||
</div>
|
||||
) : (
|
||||
isLoginMode ? '登录' : '创建账户'
|
||||
)}
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
</form>
|
||||
|
||||
{/* Social Login */}
|
||||
<motion.div
|
||||
className="mt-8"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.6 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300 dark:border-gray-600" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white dark:bg-gray-900 text-gray-500 dark:text-gray-400">
|
||||
或者
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
|
||||
disabled={isLoading}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M10 0C4.477 0 0 4.484 0 10.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0110 4.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.203 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.942.359.31.678.921.678 1.856 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0020 10.017C20 4.484 15.522 0 10 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
GitHub
|
||||
</motion.button>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
className="flex items-center justify-center px-3 py-2.5 border border-gray-300 dark:border-gray-600 rounded-xl hover:bg-gray-50 dark:hover:bg-gray-800 transition-all duration-200 font-medium text-gray-700 dark:text-gray-300 text-sm"
|
||||
disabled={isLoading}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<svg className="w-4 h-4 mr-2" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M11.5 2.5H9.5V8H11.5V2.5Z" />
|
||||
<path d="M11.5 12H9.5V17.5H11.5V12Z" />
|
||||
<path d="M6 7H2V17H6V7Z" />
|
||||
<path d="M18 7H14V17H18V7Z" />
|
||||
</svg>
|
||||
Microsoft
|
||||
</motion.button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Mode Switch */}
|
||||
<motion.div
|
||||
className="text-center mt-6"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.7 }}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{isLoginMode ? '还没有账户?' : '已有账户?'}
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchMode}
|
||||
className="text-orange-500 hover:text-orange-600 font-bold ml-1 transition-colors duration-200"
|
||||
>
|
||||
{isLoginMode ? '立即注册' : '立即登录'}
|
||||
</button>
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
@import "tailwindcss";
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--navbar-height: 64px; /* 与pt-16对应 */
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -20,7 +18,90 @@
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
background: var(--background);
|
||||
font-family: 'Inter', Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
/* Custom utility classes */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Custom component classes */
|
||||
.btn-carrot {
|
||||
background-color: #f97316;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: background-color 0.2s;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-carrot:hover {
|
||||
background-color: #ea580c;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn-carrot-outline {
|
||||
border: 2px solid #f97316;
|
||||
color: #f97316;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-carrot-outline:hover {
|
||||
background-color: #f97316;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.card-minecraft {
|
||||
background-color: white;
|
||||
border: 2px solid #fed7aa;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.card-minecraft:hover {
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.card-minecraft {
|
||||
background-color: #1f2937;
|
||||
border-color: #c2410c;
|
||||
}
|
||||
}
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(to right, #fb923c, #f97316);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bg-gradient-carrot {
|
||||
background: linear-gradient(to bottom right, #fb923c, #f97316, #ea580c);
|
||||
}
|
||||
|
||||
/* 现代布局解决方案 */
|
||||
@layer utilities {
|
||||
/* 全屏减去navbar高度 */
|
||||
.h-screen-nav {
|
||||
height: calc(100vh - var(--navbar-height));
|
||||
}
|
||||
|
||||
/* 侧栏最大高度,确保底部按钮可见 */
|
||||
.sidebar-max-height {
|
||||
max-height: calc(100vh - var(--navbar-height) - 120px);
|
||||
}
|
||||
|
||||
/* 首页hero section专用高度 */
|
||||
.min-h-screen-nav {
|
||||
min-height: calc(100vh - var(--navbar-height));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Inter } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import Navbar from "@/components/Navbar";
|
||||
import { AuthProvider } from "@/contexts/AuthContext";
|
||||
import { MainContent } from "@/components/MainContent";
|
||||
import { ErrorNotificationContainer } from "@/components/ErrorNotification";
|
||||
import ScrollToTop from "@/components/ScrollToTop";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const inter = Inter({
|
||||
subsets: ["latin"],
|
||||
weight: ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
|
||||
description: "新一代Minecraft Yggdrasil皮肤站,为创作者打造的现代化皮肤管理平台",
|
||||
keywords: "Minecraft, 皮肤站, Yggdrasil, CarrotSkin, 我的世界, 皮肤管理",
|
||||
authors: [{ name: "CarrotSkin Team" }],
|
||||
openGraph: {
|
||||
title: "CarrotSkin - 现代化Minecraft Yggdrasil皮肤站",
|
||||
description: "新一代Minecraft Yggdrasil皮肤站,为创作者打造的现代化皮肤管理平台",
|
||||
type: "website",
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +31,14 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="zh-CN">
|
||||
<body className={inter.className}>
|
||||
<AuthProvider>
|
||||
<Navbar />
|
||||
<MainContent>{children}</MainContent>
|
||||
<ErrorNotificationContainer />
|
||||
<ScrollToTop />
|
||||
</AuthProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
108
src/app/not-found.tsx
Normal file
108
src/app/not-found.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HomeIcon, ArrowLeftIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-orange-50 via-white to-amber-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
|
||||
<motion.div
|
||||
className="text-center px-4"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: 'easeOut' }}
|
||||
>
|
||||
{/* 404 数字 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ scale: 0.5, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.2, duration: 0.6, type: 'spring', stiffness: 200 }}
|
||||
>
|
||||
<h1 className="text-9xl font-black bg-gradient-to-r from-orange-400 via-orange-500 to-amber-500 bg-clip-text text-transparent mb-4">
|
||||
404
|
||||
</h1>
|
||||
<div className="w-24 h-1 bg-gradient-to-r from-orange-400 to-amber-500 mx-auto rounded-full" />
|
||||
</motion.div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
<motion.div
|
||||
className="mb-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4, duration: 0.6 }}
|
||||
>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
页面不见了
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 max-w-md mx-auto leading-relaxed">
|
||||
抱歉,我们找不到您要访问的页面。它可能已被移动、删除,或者您输入的链接不正确。
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* Minecraft 风格的装饰 */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.6, duration: 0.6 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-orange-400 to-amber-500 rounded-lg flex items-center justify-center transform rotate-12 shadow-lg">
|
||||
<span className="text-2xl font-bold text-white">?</span>
|
||||
</div>
|
||||
<div className="absolute -top-2 -right-2 w-6 h-6 bg-red-500 rounded-full flex items-center justify-center animate-pulse">
|
||||
<span className="text-white text-xs font-bold">!</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.8, duration: 0.6 }}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center px-6 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white font-semibold rounded-xl transition-all duration-200 shadow-lg hover:shadow-xl transform hover:scale-105"
|
||||
>
|
||||
<HomeIcon className="w-5 h-5 mr-2" />
|
||||
返回主页
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="inline-flex items-center px-6 py-3 border-2 border-orange-500 text-orange-500 hover:bg-orange-500 hover:text-white font-semibold rounded-xl transition-all duration-200"
|
||||
>
|
||||
<ArrowLeftIcon className="w-5 h-5 mr-2" />
|
||||
返回上页
|
||||
</button>
|
||||
</motion.div>
|
||||
|
||||
{/* 额外的帮助信息 */}
|
||||
<motion.div
|
||||
className="mt-8 text-sm text-gray-500 dark:text-gray-400"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1, duration: 0.6 }}
|
||||
>
|
||||
<p>如果问题持续存在,请
|
||||
<Link href="/contact" className="text-orange-500 hover:text-orange-600 underline">
|
||||
联系我们
|
||||
</Link>
|
||||
的支持团队
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
{/* 背景装饰 */}
|
||||
<div className="fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute top-1/4 left-1/4 w-64 h-64 bg-orange-200/20 dark:bg-orange-900/20 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-amber-200/20 dark:bg-amber-900/20 rounded-full blur-3xl" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
349
src/app/page.tsx
349
src/app/page.tsx
@@ -1,65 +1,304 @@
|
||||
import Image from "next/image";
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { motion, useScroll, useTransform } from 'framer-motion';
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ShieldCheckIcon,
|
||||
CloudArrowUpIcon,
|
||||
ShareIcon,
|
||||
CubeIcon,
|
||||
UserGroupIcon,
|
||||
SparklesIcon,
|
||||
RocketLaunchIcon
|
||||
} from '@heroicons/react/24/outline';
|
||||
|
||||
export default function Home() {
|
||||
const { scrollYProgress } = useScroll();
|
||||
const opacity = useTransform(scrollYProgress, [0, 0.3], [1, 0]);
|
||||
const scale = useTransform(scrollYProgress, [0, 0.3], [1, 0.8]);
|
||||
|
||||
const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 });
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
setMousePosition({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
window.addEventListener('mousemove', handleMouseMove);
|
||||
return () => window.removeEventListener('mousemove', handleMouseMove);
|
||||
}, []);
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: ShieldCheckIcon,
|
||||
title: "Yggdrasil认证",
|
||||
description: "完整的Minecraft Yggdrasil API支持,安全可靠的用户认证系统",
|
||||
color: "from-amber-400 to-orange-500"
|
||||
},
|
||||
{
|
||||
icon: CloudArrowUpIcon,
|
||||
title: "云端存储",
|
||||
description: "无限皮肤存储空间,自动备份,随时随地访问你的皮肤库",
|
||||
color: "from-orange-400 to-red-500"
|
||||
},
|
||||
{
|
||||
icon: ShareIcon,
|
||||
title: "社区分享",
|
||||
description: "与全球玩家分享创作,发现灵感,建立你的粉丝群体",
|
||||
color: "from-red-400 to-pink-500"
|
||||
},
|
||||
{
|
||||
icon: CubeIcon,
|
||||
title: "3D预览",
|
||||
description: "实时3D皮肤预览,360度旋转查看,支持多种渲染模式",
|
||||
color: "from-pink-400 to-purple-500"
|
||||
}
|
||||
];
|
||||
|
||||
const stats = [
|
||||
{ number: "50K+", label: "注册用户" },
|
||||
{ number: "200K+", label: "皮肤上传" },
|
||||
{ number: "1M+", label: "月活用户" },
|
||||
{ number: "99.9%", label: "服务可用性" }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
<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
|
||||
className="absolute w-32 h-32 bg-gradient-to-r from-orange-400/30 to-amber-400/30 rounded-full blur-2xl transition-all duration-300 ease-out"
|
||||
style={{
|
||||
left: mousePosition.x - 64,
|
||||
top: mousePosition.y - 64,
|
||||
transform: isHovered ? 'scale(1.5)' : 'scale(1)'
|
||||
}}
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</div>
|
||||
|
||||
{/* Hero Section */}
|
||||
<motion.section
|
||||
style={{ opacity, scale }}
|
||||
className="relative min-h-screen flex items-center justify-center px-4 sm:px-6 lg:px-8 overflow-hidden"
|
||||
>
|
||||
<div className="relative z-10 max-w-7xl mx-auto text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8, ease: "easeOut" }}
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
{/* Logo Animation */}
|
||||
<motion.div
|
||||
className="mb-8 flex justify-center"
|
||||
initial={{ scale: 0, rotate: -180 }}
|
||||
animate={{ scale: 1, rotate: 0 }}
|
||||
transition={{ duration: 1, type: "spring", stiffness: 100 }}
|
||||
>
|
||||
<div className="relative">
|
||||
<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">
|
||||
<span className="text-4xl font-bold text-white">CS</span>
|
||||
</div>
|
||||
<motion.div
|
||||
className="absolute -inset-2 bg-gradient-to-br from-orange-400/30 to-amber-500/30 rounded-3xl blur-lg"
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-6xl md:text-8xl font-black text-gray-900 dark:text-white mb-6 tracking-tight">
|
||||
<span className="bg-gradient-to-r from-orange-500 via-amber-500 to-orange-600 bg-clip-text text-transparent">
|
||||
CarrotSkin
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<motion.p
|
||||
className="text-xl md:text-2xl text-gray-600 dark:text-gray-300 mb-8 max-w-3xl mx-auto font-light leading-relaxed"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.3, duration: 0.6 }}
|
||||
>
|
||||
新一代 <span className="font-semibold text-orange-500">Minecraft Yggdrasil</span> 皮肤站
|
||||
<br className="hidden sm:block" />
|
||||
为创作者打造的现代化皮肤管理平台
|
||||
</motion.p>
|
||||
|
||||
<motion.div
|
||||
className="flex flex-col sm:flex-row gap-4 justify-center items-center"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5, duration: 0.6 }}
|
||||
>
|
||||
<Link
|
||||
href="/register"
|
||||
className="group relative overflow-hidden bg-gradient-to-r from-orange-500 to-amber-500 text-white font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:shadow-2xl hover:shadow-orange-500/25"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<span className="relative z-10 flex items-center space-x-2">
|
||||
<span>立即开始</span>
|
||||
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</span>
|
||||
<motion.div
|
||||
className="absolute inset-0 bg-gradient-to-r from-amber-500 to-orange-500"
|
||||
initial={{ x: '-100%' }}
|
||||
whileHover={{ x: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/skins"
|
||||
className="group border-2 border-orange-200 dark:border-orange-700 text-orange-600 dark:text-orange-400 font-semibold py-4 px-8 rounded-2xl transition-all duration-300 hover:bg-orange-500 hover:text-white hover:border-orange-500"
|
||||
>
|
||||
<span className="flex items-center space-x-2">
|
||||
<span>探索皮肤库</span>
|
||||
<SparklesIcon className="w-5 h-5 group-hover:rotate-12 transition-transform" />
|
||||
</span>
|
||||
</Link>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<motion.div
|
||||
className="absolute bottom-12 left-1/2 transform -translate-x-1/2"
|
||||
animate={{ y: [0, 10, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
>
|
||||
<div className="w-6 h-10 border-2 border-orange-400 rounded-full flex justify-center">
|
||||
<motion.div
|
||||
className="w-1 h-3 bg-orange-400 rounded-full mt-2"
|
||||
animate={{ y: [0, 16, 0] }}
|
||||
transition={{ duration: 2, repeat: Infinity }}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.section>
|
||||
|
||||
{/* Stats Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-8">
|
||||
{stats.map((stat, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center group"
|
||||
>
|
||||
<div className="text-4xl md:text-5xl font-black bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent mb-2">
|
||||
{stat.number}
|
||||
</div>
|
||||
<div className="text-gray-600 dark:text-gray-400 font-medium">
|
||||
{stat.label}
|
||||
</div>
|
||||
<div className="h-1 w-16 bg-gradient-to-r from-orange-400 to-amber-400 mx-auto mt-3 rounded-full group-hover:w-24 transition-all duration-300"></div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8 relative">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h2 className="text-4xl md:text-5xl font-black text-gray-900 dark:text-white mb-6">
|
||||
为创作者而生的
|
||||
<span className="bg-gradient-to-r from-orange-500 to-amber-500 bg-clip-text text-transparent"> 强大功能</span>
|
||||
</h2>
|
||||
<p className="text-xl text-gray-600 dark:text-gray-400 max-w-3xl mx-auto font-light">
|
||||
从上传到分享,从管理到展示,每一个细节都为提升你的创作体验而设计
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, x: index % 2 === 0 ? -50 : 50 }}
|
||||
whileInView={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: index * 0.1, duration: 0.6 }}
|
||||
viewport={{ once: true }}
|
||||
className="group relative"
|
||||
>
|
||||
<div className="bg-white/70 dark:bg-gray-800/70 backdrop-blur-lg rounded-3xl p-8 shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 dark:border-gray-700/50">
|
||||
<div className={`w-16 h-16 bg-gradient-to-br ${feature.color} rounded-2xl flex items-center justify-center mb-6 shadow-lg group-hover:scale-110 transition-transform duration-300`}>
|
||||
<feature.icon className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-300 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-orange-500 via-amber-500 to-orange-600">
|
||||
<div className="absolute inset-0 bg-black/10"></div>
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
className="relative z-10 max-w-4xl mx-auto text-center"
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
viewport={{ once: true }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 20, repeat: Infinity, ease: "linear" }}
|
||||
className="w-32 h-32 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-8"
|
||||
>
|
||||
<RocketLaunchIcon className="w-16 h-16 text-white/80" />
|
||||
</motion.div>
|
||||
|
||||
<h2 className="text-4xl md:text-5xl font-black text-white mb-6">
|
||||
准备开启创作之旅?
|
||||
</h2>
|
||||
<p className="text-xl text-white/90 mb-8 max-w-2xl mx-auto font-light">
|
||||
加入CarrotSkin,体验新一代Minecraft皮肤管理平台,让你的创意无限绽放
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
href="/register"
|
||||
className="group bg-white text-orange-600 hover:bg-gray-100 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2 shadow-2xl"
|
||||
>
|
||||
<span>免费注册</span>
|
||||
<ArrowRightIcon className="w-5 h-5 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/api"
|
||||
className="border-2 border-white/30 text-white hover:bg-white/10 font-bold py-4 px-8 rounded-2xl transition-all duration-300 inline-flex items-center space-x-2"
|
||||
>
|
||||
<span>查看API文档</span>
|
||||
<UserGroupIcon className="w-5 h-5" />
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
1359
src/app/profile/page.tsx
Normal file
1359
src/app/profile/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
462
src/app/skins/page.tsx
Normal file
462
src/app/skins/page.tsx
Normal file
@@ -0,0 +1,462 @@
|
||||
'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 { 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 { 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]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTextures();
|
||||
}, [loadTextures]);
|
||||
|
||||
// 处理收藏
|
||||
const handleFavorite = async (id: number) => {
|
||||
if (!isAuthenticated) {
|
||||
alert('请先登录');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await toggleFavorite(id);
|
||||
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
|
||||
));
|
||||
}
|
||||
} 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>
|
||||
|
||||
<div className="relative z-0 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"
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
皮肤库
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
发现和分享精彩的Minecraft皮肤与披风
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{/* 重新设计的搜索区域 - 更紧凑专业 */}
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 类型筛选 - 更紧凑 */}
|
||||
<div className="lg:w-48">
|
||||
<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 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>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Textures Grid - 保持卡片设计但简化 */}
|
||||
<AnimatePresence>
|
||||
{!isLoading && (
|
||||
<motion.div
|
||||
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"
|
||||
>
|
||||
{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"
|
||||
>
|
||||
<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">
|
||||
<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={() => 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>
|
||||
</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="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>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user