feat: 完成navbar隐藏优化和侧边栏冻结功能

- 优化navbar滚动隐藏逻辑,更敏感响应
- 添加返回顶部按钮,固定在右下角
- 实现profile页面侧边栏真正冻结效果
- 修复首页滑动指示器位置
- 优化整体布局确保首屏内容完整显示
This commit is contained in:
Wuying Created Local Users
2025-12-04 20:05:13 +08:00
parent 570e864e06
commit 5f90f48a1c
25 changed files with 7493 additions and 118 deletions

View File

@@ -0,0 +1,194 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { XMarkIcon, ExclamationTriangleIcon, CheckCircleIcon, InformationCircleIcon } from '@heroicons/react/24/outline';
export type ErrorType = 'error' | 'warning' | 'success' | 'info';
interface ErrorNotificationProps {
message: string;
type?: ErrorType;
duration?: number;
onClose?: () => void;
}
export function ErrorNotification({ message, type = 'error', duration = 5000, onClose }: ErrorNotificationProps) {
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
if (duration > 0) {
const timer = setTimeout(() => {
setIsVisible(false);
onClose?.();
}, duration);
return () => clearTimeout(timer);
}
}, [duration, onClose]);
const handleClose = () => {
setIsVisible(false);
onClose?.();
};
const getIcon = () => {
switch (type) {
case 'error':
return <ExclamationTriangleIcon className="w-5 h-5" />;
case 'warning':
return <ExclamationTriangleIcon className="w-5 h-5" />;
case 'success':
return <CheckCircleIcon className="w-5 h-5" />;
case 'info':
return <InformationCircleIcon className="w-5 h-5" />;
}
};
const getStyles = () => {
switch (type) {
case 'error':
return {
bg: 'bg-red-50 dark:bg-red-900/20',
border: 'border-red-200 dark:border-red-800',
text: 'text-red-800 dark:text-red-200',
icon: 'text-red-500',
close: 'text-red-400 hover:text-red-600 dark:text-red-300 dark:hover:text-red-100'
};
case 'warning':
return {
bg: 'bg-yellow-50 dark:bg-yellow-900/20',
border: 'border-yellow-200 dark:border-yellow-800',
text: 'text-yellow-800 dark:text-yellow-200',
icon: 'text-yellow-500',
close: 'text-yellow-400 hover:text-yellow-600 dark:text-yellow-300 dark:hover:text-yellow-100'
};
case 'success':
return {
bg: 'bg-green-50 dark:bg-green-900/20',
border: 'border-green-200 dark:border-green-800',
text: 'text-green-800 dark:text-green-200',
icon: 'text-green-500',
close: 'text-green-400 hover:text-green-600 dark:text-green-300 dark:hover:text-green-100'
};
case 'info':
return {
bg: 'bg-blue-50 dark:bg-blue-900/20',
border: 'border-blue-200 dark:border-blue-800',
text: 'text-blue-800 dark:text-blue-200',
icon: 'text-blue-500',
close: 'text-blue-400 hover:text-blue-600 dark:text-blue-300 dark:hover:text-blue-100'
};
}
};
const styles = getStyles();
return (
<AnimatePresence>
{isVisible && (
<motion.div
initial={{ opacity: 0, y: -20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -20, scale: 0.9 }}
transition={{ duration: 0.3, ease: 'easeOut' }}
className={`fixed top-4 right-4 z-50 max-w-sm w-full ${styles.bg} ${styles.border} border rounded-xl shadow-lg backdrop-blur-sm`}
>
<div className="flex items-start p-4">
<div className={`flex-shrink-0 ${styles.icon} mr-3 mt-0.5`}>
{getIcon()}
</div>
<div className="flex-1">
<p className={`text-sm font-medium ${styles.text}`}>
{message}
</p>
</div>
<button
onClick={handleClose}
className={`flex-shrink-0 ml-3 ${styles.close} transition-colors`}
>
<XMarkIcon className="w-5 h-5" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
);
}
// 全局错误管理器
class ErrorManager {
private static instance: ErrorManager;
private listeners: Array<(notification: ErrorNotificationProps & { id: string }) => void> = [];
static getInstance(): ErrorManager {
if (!ErrorManager.instance) {
ErrorManager.instance = new ErrorManager();
}
return ErrorManager.instance;
}
showError(message: string, duration?: number) {
this.showNotification(message, 'error', duration);
}
showWarning(message: string, duration?: number) {
this.showNotification(message, 'warning', duration);
}
showSuccess(message: string, duration?: number) {
this.showNotification(message, 'success', duration);
}
showInfo(message: string, duration?: number) {
this.showNotification(message, 'info', duration);
}
private showNotification(message: string, type: ErrorType, duration?: number) {
const notification = {
id: Math.random().toString(36).substr(2, 9),
message,
type,
duration: duration ?? (type === 'error' ? 5000 : 3000)
};
this.listeners.forEach(listener => listener(notification));
}
subscribe(listener: (notification: ErrorNotificationProps & { id: string }) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter(l => l !== listener);
};
}
}
export const errorManager = ErrorManager.getInstance();
// 错误提示容器组件
export function ErrorNotificationContainer() {
const [notifications, setNotifications] = useState<Array<ErrorNotificationProps & { id: string }>>([]);
useEffect(() => {
const unsubscribe = errorManager.subscribe((notification) => {
setNotifications(prev => [...prev, notification]);
});
return unsubscribe;
}, []);
const removeNotification = (id: string) => {
setNotifications(prev => prev.filter(n => n.id !== id));
};
return (
<>
{notifications.map((notification) => (
<ErrorNotification
key={notification.id}
{...notification}
onClose={() => removeNotification(notification.id)}
/>
))}
</>
);
}

View File

@@ -0,0 +1,279 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
import {
HomeIcon,
ArrowLeftIcon,
ExclamationTriangleIcon,
XCircleIcon,
ClockIcon,
ServerIcon,
WifiIcon
} from '@heroicons/react/24/outline';
export interface ErrorPageProps {
code?: number;
title?: string;
message?: string;
description?: string;
type?: '404' | '500' | '403' | 'network' | 'timeout' | 'maintenance' | 'custom';
actions?: {
primary?: {
label: string;
href?: string;
onClick?: () => void;
};
secondary?: {
label: string;
href?: string;
onClick?: () => void;
};
};
showContact?: boolean;
}
const errorConfigs = {
'404': {
icon: <XCircleIcon className="w-16 h-16" />,
title: '页面不见了',
message: '抱歉,我们找不到您要访问的页面。',
description: '它可能已被移动、删除,或者您输入的链接不正确。'
},
'500': {
icon: <ServerIcon className="w-16 h-16" />,
title: '服务器错误',
message: '抱歉,服务器遇到了一些问题。',
description: '我们的团队正在努力解决这个问题,请稍后再试。'
},
'403': {
icon: <ExclamationTriangleIcon className="w-16 h-16" />,
title: '访问被拒绝',
message: '抱歉,您没有权限访问此页面。',
description: '请检查您的账户权限或联系管理员。'
},
'network': {
icon: <WifiIcon className="w-16 h-16" />,
title: '网络连接错误',
message: '无法连接到服务器。',
description: '请检查您的网络连接,然后重试。'
},
'timeout': {
icon: <ClockIcon className="w-16 h-16" />,
title: '请求超时',
message: '请求处理时间过长。',
description: '请刷新页面或稍后再试。'
},
'maintenance': {
icon: <ServerIcon className="w-16 h-16" />,
title: '系统维护中',
message: '我们正在进行系统维护。',
description: '请稍后再试,我们会尽快恢复服务。'
}
};
export function ErrorPage({
code,
title,
message,
description,
type = 'custom',
actions,
showContact = true
}: ErrorPageProps) {
const config = errorConfigs[type] || {};
const displayTitle = title || config.title || '出错了';
const displayMessage = message || config.message || '发生了一些错误';
const displayDescription = description || config.description || '';
const defaultActions = {
primary: {
label: '返回主页',
href: '/'
},
secondary: {
label: '返回上页',
onClick: () => window.history.back()
}
};
const finalActions = { ...defaultActions, ...actions };
const getIconColor = () => {
switch (type) {
case '404': return 'text-orange-500';
case '500': return 'text-red-500';
case '403': return 'text-yellow-500';
case 'network': return 'text-blue-500';
case 'timeout': return 'text-purple-500';
case 'maintenance': return 'text-gray-500';
default: return 'text-orange-500';
}
};
const getCodeColor = () => {
switch (type) {
case '404': return 'from-orange-400 via-orange-500 to-amber-500';
case '500': return 'from-red-400 via-red-500 to-pink-500';
case '403': return 'from-yellow-400 via-yellow-500 to-orange-500';
case 'network': return 'from-blue-400 via-blue-500 to-cyan-500';
case 'timeout': return 'from-purple-400 via-purple-500 to-pink-500';
case 'maintenance': return 'from-gray-400 via-gray-500 to-slate-500';
default: return 'from-orange-400 via-orange-500 to-amber-500';
}
};
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 max-w-2xl mx-auto"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
>
{/* 错误代码 */}
{code && (
<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 ${getCodeColor()} bg-clip-text text-transparent mb-4`}>
{code}
</h1>
<div className={`w-24 h-1 bg-gradient-to-r ${getCodeColor()} mx-auto rounded-full`} />
</motion.div>
)}
{/* 图标 */}
<motion.div
className="mb-8 flex justify-center"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.4, duration: 0.6 }}
>
<div className={`${getIconColor()}`}>
{config.icon || <ExclamationTriangleIcon className="w-16 h-16" />}
</div>
</motion.div>
{/* 错误信息 */}
<motion.div
className="mb-8"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.6 }}
>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
{displayTitle}
</h2>
<p className="text-xl text-gray-700 dark:text-gray-300 mb-2">
{displayMessage}
</p>
{displayDescription && (
<p className="text-lg text-gray-600 dark:text-gray-400 leading-relaxed">
{displayDescription}
</p>
)}
</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 }}
>
{finalActions.primary && (
finalActions.primary.href ? (
<Link
href={finalActions.primary.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" />
{finalActions.primary.label}
</Link>
) : (
<button
onClick={finalActions.primary.onClick}
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" />
{finalActions.primary.label}
</button>
)
)}
{finalActions.secondary && (
finalActions.secondary.href ? (
<Link
href={finalActions.secondary.href}
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" />
{finalActions.secondary.label}
</Link>
) : (
<button
onClick={finalActions.secondary.onClick}
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" />
{finalActions.secondary.label}
</button>
)
)}
</motion.div>
{/* 联系信息 */}
{showContact && (
<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 mx-1">
</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>
);
}
// 预设的错误页面组件
export function NotFoundPage() {
return <ErrorPage type="404" code={404} />;
}
export function ServerErrorPage() {
return <ErrorPage type="500" code={500} />;
}
export function ForbiddenPage() {
return <ErrorPage type="403" code={403} />;
}
export function NetworkErrorPage() {
return <ErrorPage type="network" />;
}
export function TimeoutErrorPage() {
return <ErrorPage type="timeout" />;
}
export function MaintenancePage() {
return <ErrorPage type="maintenance" />;
}

View File

@@ -0,0 +1,19 @@
'use client';
import { usePathname } from 'next/navigation';
export function MainContent({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const isAuthPage = pathname === '/auth';
const isHomePage = pathname === '/';
return (
<main 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
${isAuthPage || isHomePage ? '' : 'pt-16'}
`}>
{children}
</main>
);
}

387
src/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,387 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import Link from 'next/link';
import { useRouter, usePathname } from 'next/navigation';
import { Bars3Icon, XMarkIcon, UserCircleIcon } from '@heroicons/react/24/outline';
import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '@/contexts/AuthContext';
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false);
const [isHidden, setIsHidden] = useState(false);
const [isScrolled, setIsScrolled] = useState(false);
const [showScrollTop, setShowScrollTop] = useState(false);
const [navbarHeight, setNavbarHeight] = useState(0);
const navbarRef = useRef<HTMLElement>(null);
const { user, isAuthenticated, logout } = useAuth();
const router = useRouter();
const pathname = usePathname();
// 在auth页面隐藏navbar
const isAuthPage = pathname === '/auth';
// 检测navbar高度并设置CSS自定义属性
useEffect(() => {
const updateHeight = () => {
if (navbarRef.current) {
const height = navbarRef.current.offsetHeight;
setNavbarHeight(height);
document.documentElement.style.setProperty('--navbar-height', `${height}px`);
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}, []);
useEffect(() => {
let lastScrollY = 0;
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// 检测是否滚动到顶部
setIsScrolled(currentScrollY > 20);
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
// 更敏感的隐藏逻辑:只要往下滚动就隐藏,不管滚动多少
if (!isAuthPage && currentScrollY > lastScrollY && currentScrollY > 10) {
setIsHidden(true);
} else if (currentScrollY < lastScrollY) { // 往上滚动就显示
setIsHidden(false);
}
lastScrollY = currentScrollY;
ticking = false;
});
ticking = true;
}
};
if (!isAuthPage) {
window.addEventListener('scroll', handleScroll, { passive: true });
}
return () => window.removeEventListener('scroll', handleScroll);
}, [isAuthPage]);
const handleLogout = () => {
logout();
router.push('/');
setIsOpen(false);
};
const handleLinkClick = () => {
setIsOpen(false);
};
// 在auth页面不渲染navbar
if (isAuthPage) {
return null;
}
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth',
});
};
return (
<motion.nav
ref={navbarRef}
initial={{ y: 0 }}
animate={{ y: isHidden ? -100 : 0 }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
className="fixed top-0 left-0 right-0 z-50 transition-all duration-300 bg-white/80 dark:bg-gray-800/80 backdrop-blur-lg border-b border-gray-200/50 dark:border-gray-700/50"
style={{ willChange: 'transform' }}
>
<div className={`
max-w-7xl mx-auto px-4 sm:px-6 lg:px-8
${isScrolled ? 'py-3' : 'py-4'}
transition-all duration-300
`}>
<div className="flex justify-between items-center">
{/* Logo */}
<motion.div
className="flex items-center"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Link href="/" className="flex items-center space-x-3 group">
<motion.div
className="w-10 h-10 bg-gradient-to-br from-orange-400 via-orange-500 to-orange-600 rounded-xl flex items-center justify-center shadow-lg"
whileHover={{ rotate: 5, scale: 1.05 }}
transition={{ type: 'spring', stiffness: 300 }}
>
<span className="text-white font-bold text-lg">C</span>
</motion.div>
<motion.span
className="text-2xl font-black bg-gradient-to-r from-orange-400 to-orange-600 bg-clip-text text-transparent"
whileHover={{ scale: 1.02 }}
>
CarrotSkin
</motion.span>
</Link>
</motion.div>
{/* Desktop Navigation */}
<div className="hidden md:flex items-center space-x-6">
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/"
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
>
<motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.2 }}
/>
</Link>
</motion.div>
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/skins"
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 font-medium relative group px-3 py-2 rounded-lg hover:bg-orange-500/10 dark:hover:bg-orange-400/10"
>
<motion.span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-gradient-to-r from-orange-400 to-orange-600"
initial={{ scaleX: 0 }}
whileHover={{ scaleX: 1 }}
transition={{ duration: 0.2 }}
/>
</Link>
</motion.div>
{/* 用户头像框 - 类似知乎和哔哩哔哩的设计 */}
{isAuthenticated ? (
<div className="flex items-center space-x-4">
<motion.div whileHover={{ scale: 1.05 }} whileTap={{ scale: 0.95 }}>
<Link
href="/profile"
className="flex items-center space-x-3 group"
onClick={handleLinkClick}
>
{user?.avatar ? (
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<img
src={user.avatar}
alt={user.username}
className="w-9 h-9 rounded-full border-2 border-orange-500/30 group-hover:border-orange-500 transition-all duration-200 shadow-md"
/>
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
) : (
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<UserCircleIcon className="w-9 h-9 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
)}
<motion.span
className="text-gray-700 dark:text-gray-300 group-hover:text-orange-500 dark:group-hover:text-orange-400 transition-all duration-200 font-medium"
whileHover={{ scale: 1.02 }}
>
{user?.username}
</motion.span>
</Link>
</motion.div>
<motion.button
onClick={handleLogout}
className="relative overflow-hidden border-2 border-orange-500 text-orange-500 hover:text-white font-medium py-2 px-4 rounded-lg transition-all duration-200 group"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<motion.span
className="absolute inset-0 w-0 bg-gradient-to-r from-orange-400 to-orange-600 transition-all duration-300 group-hover:w-full"
initial={{ width: 0 }}
whileHover={{ width: '100%' }}
/>
<span className="relative z-10">退</span>
</motion.button>
</div>
) : (
<motion.div whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }}>
<Link
href="/auth"
className="flex items-center space-x-2 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-all duration-200 group"
>
<motion.div
className="relative"
whileHover={{ scale: 1.1 }}
>
<UserCircleIcon className="w-7 h-7 text-gray-400 group-hover:text-orange-500 transition-all duration-200" />
<motion.div
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/20 to-orange-600/20"
initial={{ opacity: 0 }}
whileHover={{ opacity: 1 }}
transition={{ duration: 0.2 }}
/>
</motion.div>
<span className="font-medium"></span>
</Link>
</motion.div>
)}
</div>
{/* Mobile menu button */}
<div className="md:hidden flex items-center">
<motion.button
onClick={() => setIsOpen(!isOpen)}
className="text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 transition-colors duration-200 p-2"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
{isOpen ? <XMarkIcon className="w-6 h-6" /> : <Bars3Icon className="w-6 h-6" />}
</motion.button>
</div>
</div>
</div>
{/* Mobile Navigation */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.2, ease: 'easeInOut' }}
className="md:hidden bg-white/95 dark:bg-gray-900/95 backdrop-blur-md border-t border-gray-200/50 dark:border-gray-700/50"
>
<div className="px-4 py-4 space-y-1">
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.05 }}
>
<Link
href="/"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.1 }}
>
<Link
href="/skins"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
{isAuthenticated ? (
<>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Link
href="/profile"
className="block px-4 py-3 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200"
onClick={handleLinkClick}
>
<div className="flex items-center space-x-3">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.username}
className="w-8 h-8 rounded-full border-2 border-orange-500/30"
/>
) : (
<UserCircleIcon className="w-8 h-8 text-gray-400" />
)}
<span className="text-gray-700 dark:text-gray-300 font-medium">{user?.username}</span>
</div>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.25 }}
>
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
>
退
</button>
</motion.div>
</>
) : (
<>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.2 }}
>
<Link
href="/auth"
className="block px-4 py-3 text-gray-700 dark:text-gray-300 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-orange-500/10 dark:hover:bg-orange-400/10 rounded-lg transition-all duration-200 font-medium"
onClick={handleLinkClick}
>
</Link>
</motion.div>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.25 }}
>
<Link
href="/auth"
className="block px-4 py-3 bg-gradient-to-r from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-lg transition-all duration-200 shadow-lg hover:shadow-xl font-medium text-center"
onClick={handleLinkClick}
>
</Link>
</motion.div>
</>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.nav>
);
}

View File

@@ -0,0 +1,65 @@
'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
export default function ScrollToTop() {
const [showScrollTop, setShowScrollTop] = useState(false);
useEffect(() => {
let ticking = false;
const handleScroll = () => {
if (!ticking) {
window.requestAnimationFrame(() => {
const currentScrollY = window.scrollY;
// 显示返回顶部按钮滚动超过300px
setShowScrollTop(currentScrollY > 300);
ticking = false;
});
ticking = true;
}
};
window.addEventListener('scroll', handleScroll, { passive: true });
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({
top: 0,
behavior: 'smooth'
});
};
return (
<AnimatePresence>
{showScrollTop && (
<motion.button
initial={{ opacity: 0, scale: 0.8, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.8, y: 20 }}
transition={{ duration: 0.2, ease: 'easeOut' }}
onClick={scrollToTop}
className="fixed bottom-6 right-6 w-12 h-12 bg-gradient-to-br from-orange-500 to-orange-600 hover:from-orange-600 hover:to-orange-700 text-white rounded-full shadow-lg hover:shadow-xl transition-all duration-200 flex items-center justify-center z-[100] group"
whileHover={{ scale: 1.1, y: -2 }}
whileTap={{ scale: 0.9 }}
>
<svg
className="w-5 h-5 transition-transform duration-200 group-hover:-translate-y-0.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 10l7-7m0 0l7 7m-7-7v18"
/>
</svg>
</motion.button>
)}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,218 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { SkinViewer as SkinViewer3D, WalkingAnimation, FunctionAnimation } from 'skinview3d';
interface SkinViewerProps {
skinUrl: string;
capeUrl?: string;
isSlim?: boolean;
width?: number;
height?: number;
className?: string;
autoRotate?: boolean;
walking?: boolean;
}
export default function SkinViewer({
skinUrl,
capeUrl,
isSlim = false,
width = 300,
height = 300,
className = '',
autoRotate = true,
walking = false,
}: SkinViewerProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const viewerRef = useRef<SkinViewer3D | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [hasError, setHasError] = useState(false);
const [imageLoaded, setImageLoaded] = useState(false);
// 预加载皮肤图片以检查是否可访问
useEffect(() => {
if (!skinUrl) return;
setIsLoading(true);
setHasError(false);
setImageLoaded(false);
const img = new Image();
img.crossOrigin = 'anonymous'; // 尝试跨域访问
img.onload = () => {
console.log('皮肤图片加载成功:', skinUrl);
setImageLoaded(true);
setIsLoading(false);
};
img.onerror = (error) => {
console.error('皮肤图片加载失败:', skinUrl, error);
setHasError(true);
setIsLoading(false);
};
// 开始加载图片
img.src = skinUrl;
return () => {
// 清理
img.onload = null;
img.onerror = null;
};
}, [skinUrl]);
// 初始化3D查看器
useEffect(() => {
if (!canvasRef.current || !imageLoaded || hasError) return;
try {
console.log('初始化3D皮肤查看器:', { skinUrl, isSlim, width, height });
// 使用canvas的实际尺寸参考blessingskin
const canvas = canvasRef.current;
const viewer = new SkinViewer3D({
canvas: canvas,
width: canvas.clientWidth || width,
height: canvas.clientHeight || height,
skin: skinUrl,
cape: capeUrl,
model: isSlim ? 'slim' : 'default',
zoom: 1.0, // 使用blessingskin的zoom方式
});
viewerRef.current = viewer;
// 设置背景和控制选项 - 参考blessingskin
viewer.background = null; // 透明背景
viewer.autoRotate = false; // 禁用自动旋转
// 禁用所有交互控制
viewer.controls.enableRotate = false; // 禁用旋转控制
viewer.controls.enableZoom = false; // 禁用缩放
viewer.controls.enablePan = false; // 禁用平移
console.log('3D皮肤查看器初始化成功');
} catch (error) {
console.error('3D皮肤查看器初始化失败:', error);
setHasError(true);
}
// 清理函数
return () => {
if (viewerRef.current) {
try {
viewerRef.current.dispose();
viewerRef.current = null;
console.log('3D皮肤查看器已清理');
} catch (error) {
console.error('清理3D皮肤查看器失败:', error);
}
}
};
}, [skinUrl, capeUrl, isSlim, width, height, autoRotate, walking, imageLoaded, hasError]);
// 当皮肤URL改变时更新
useEffect(() => {
if (viewerRef.current && skinUrl && imageLoaded) {
try {
console.log('更新皮肤URL:', skinUrl);
viewerRef.current.loadSkin(skinUrl);
} catch (error) {
console.error('更新皮肤失败:', error);
}
}
}, [skinUrl, imageLoaded]);
// 监听容器尺寸变化并调整viewer大小 - 参考blessingskin
useEffect(() => {
if (viewerRef.current && canvasRef.current) {
const rect = canvasRef.current.getBoundingClientRect();
viewerRef.current.setSize(rect.width, rect.height);
}
}, [imageLoaded]); // 当图片加载完成后调整尺寸
// 当披风URL改变时更新
useEffect(() => {
if (viewerRef.current && capeUrl && imageLoaded) {
try {
console.log('更新披风URL:', capeUrl);
viewerRef.current.loadCape(capeUrl);
} catch (error) {
console.error('更新披风失败:', error);
}
} else if (viewerRef.current && !capeUrl && imageLoaded) {
try {
viewerRef.current.loadCape(null);
} catch (error) {
console.error('移除披风失败:', error);
}
}
}, [capeUrl, imageLoaded]);
// 当模型类型改变时更新
useEffect(() => {
if (viewerRef.current && skinUrl && imageLoaded) {
try {
console.log('更新模型类型:', isSlim ? 'slim' : 'default');
viewerRef.current.loadSkin(skinUrl, { model: isSlim ? 'slim' : 'default' });
} catch (error) {
console.error('更新模型失败:', error);
}
}
}, [isSlim, skinUrl, imageLoaded]);
// 错误状态显示
if (hasError) {
return (
<div
className={`${className} flex items-center justify-center bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-900/20 dark:to-orange-900/20 rounded-xl border-2 border-dashed border-red-300 dark:border-red-700`}
style={{ width: width, height: height }}
>
<div className="text-center p-4">
<div className="text-4xl mb-2"></div>
<div className="text-sm text-red-600 dark:text-red-400 font-medium mb-1">
</div>
<div className="text-xs text-red-500 dark:text-red-500">
访
</div>
</div>
</div>
);
}
// 加载状态显示
if (isLoading) {
return (
<div
className={`${className} flex items-center justify-center bg-gradient-to-br from-orange-50 to-amber-50 dark:from-gray-700 dark:to-gray-600 rounded-xl`}
style={{ width: width, height: height }}
>
<div className="text-center">
<div className="animate-spin w-8 h-8 border-3 border-orange-500 border-t-transparent rounded-full mx-auto mb-3"></div>
<div className="text-sm text-orange-600 dark:text-orange-400 font-medium">
...
</div>
</div>
</div>
);
}
return (
<canvas
ref={canvasRef}
className={className}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%'
}}
/>
);
}