更新皮肤分类功能,添加帮助页面和Yggdrasil教程
This commit is contained in:
@@ -1,59 +1,275 @@
|
||||
// src/components/Navbar.tsx
|
||||
'use client';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { getServerSession } from 'next-auth';
|
||||
import { authOptions } from '@/lib/api/auth';
|
||||
import { signOut } from '@/lib/api/auth';
|
||||
import { Session } from 'next-auth';
|
||||
import { serverSignOut } from '@/lib/api/actions';
|
||||
import { useState, FunctionComponent } from 'react';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
|
||||
export default async function Navbar() {
|
||||
const session = await getServerSession(authOptions);
|
||||
// 定义组件Props接口
|
||||
interface NavbarProps {
|
||||
session: Session | null;
|
||||
}
|
||||
|
||||
// 处理客户端退出函数
|
||||
const handleSignOut = async () => {
|
||||
'use server';
|
||||
await signOut();
|
||||
};
|
||||
// 移动端菜单组件
|
||||
interface MobileMenuProps {
|
||||
session: Session | null;
|
||||
userName: string;
|
||||
serverSignOut: () => void;
|
||||
handleProtectedLinkClick: () => void;
|
||||
}
|
||||
|
||||
const MobileMenu: FunctionComponent<MobileMenuProps> = ({ session, userName, serverSignOut, handleProtectedLinkClick }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="md:hidden">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="text-white hover:bg-gray-700"
|
||||
>
|
||||
{isOpen ? <X size={24} /> : <Menu size={24} />}
|
||||
</Button>
|
||||
|
||||
{/* 移动端下拉菜单 */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-16 right-4 bg-gray-800 border border-gray-700 rounded-lg shadow-lg py-2 w-48 z-50">
|
||||
<div className="space-y-2 px-2">
|
||||
{session ? (
|
||||
<>
|
||||
<Link
|
||||
href="/user-home"
|
||||
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
用户主页
|
||||
</Link>
|
||||
<Link
|
||||
href="/skins"
|
||||
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
皮肤库
|
||||
</Link>
|
||||
<Link
|
||||
href="/character-center"
|
||||
className="block px-4 py-2 hover:bg-gray-700 rounded hover:text-emerald-400 transition-colors"
|
||||
onClick={() => setIsOpen(false)}
|
||||
>
|
||||
角色中心
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleProtectedLinkClick();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
用户主页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleProtectedLinkClick();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
皮肤库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
handleProtectedLinkClick();
|
||||
setIsOpen(false);
|
||||
}}
|
||||
className="block w-full text-left px-4 py-2 hover:bg-gray-700 rounded cursor-pointer hover:text-emerald-400 transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
角色中心
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{session && (
|
||||
<div className="border-t border-gray-700 my-1 pt-2">
|
||||
<p className="px-4 mb-2 text-sm text-gray-300">你好, {userName}</p>
|
||||
<form action={serverSignOut} className="w-full">
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="w-full text-red-300 border-white hover:bg-gray-700 hover:text-red-200"
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!session && (
|
||||
<div className="border-t border-gray-700 my-1 pt-2 space-y-2">
|
||||
<Button asChild variant="outline" className="w-full text-emerald-400 border-emerald-400/30 hover:bg-gray-700 hover:text-emerald-300 transition-all duration-300">
|
||||
<Link href="/login" onClick={() => setIsOpen(false)}>登录</Link>
|
||||
</Button>
|
||||
<Button asChild className="w-full bg-emerald-600 hover:bg-emerald-700 transition-all duration-300 transform hover:-translate-y-0.5">
|
||||
<Link href="/register" onClick={() => setIsOpen(false)}>注册</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// 移动Navbar组件到客户端
|
||||
const Navbar: FunctionComponent<NavbarProps> = ({ session }) => {
|
||||
|
||||
// 添加日志以便调试
|
||||
console.log('Navbar会话状态:', session ? '已登录' : '未登录');
|
||||
console.log('会话用户:', session?.user);
|
||||
|
||||
// 安全地获取用户名
|
||||
const userName = session?.user?.name || '玩家';
|
||||
|
||||
// 控制登录提示弹窗的状态
|
||||
const [showLoginPrompt, setShowLoginPrompt] = useState(false);
|
||||
|
||||
// 处理需要登录的链接点击
|
||||
const handleProtectedLinkClick = () => {
|
||||
if (!session) {
|
||||
setShowLoginPrompt(true);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bg-gray-800 text-white py-4">
|
||||
<div className="container mx-auto px-4 flex justify-between items-center">
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="/" className="text-xl font-bold">我的世界皮肤库</Link>
|
||||
<div className="hidden md:flex space-x-4">
|
||||
<Link href="/dashboard" className="hover:text-gray-300">仪表盘</Link>
|
||||
<Link href="/skins" className="hover:text-gray-300">皮肤库</Link>
|
||||
<>
|
||||
<nav className="bg-gray-900 text-white py-4 shadow-md backdrop-blur-sm bg-opacity-90 z-100">
|
||||
<div className="container mx-auto px-4 flex justify-between items-center">
|
||||
<div className="flex items-center space-x-6">
|
||||
<Link href="/" className="flex items-center space-x-2 group">
|
||||
<Image
|
||||
src="/images/mc-favicon.ico"
|
||||
alt="Logo"
|
||||
width={32}
|
||||
height={32}
|
||||
className="rounded-xl w-8 h-8 transition-transform duration-300 group-hover:scale-110"
|
||||
/>
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-emerald-400 to-teal-400 text-transparent bg-clip-text">我的世界皮肤库</span>
|
||||
</Link>
|
||||
{/* 桌面端导航链接 */}
|
||||
<div className="hidden md:flex space-x-4">
|
||||
{session ? (
|
||||
<>
|
||||
<Link href="/user-home" className="hover:text-emerald-400 transition-colors">用户主页</Link>
|
||||
<Link href="/skins" className="hover:text-emerald-400 transition-colors">皮肤库</Link>
|
||||
<Link href="/character-center" className="hover:text-emerald-400 transition-colors">角色中心</Link>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={handleProtectedLinkClick}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
用户主页
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProtectedLinkClick}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
皮肤库
|
||||
</button>
|
||||
<button
|
||||
onClick={handleProtectedLinkClick}
|
||||
className="hover:text-emerald-400 cursor-pointer transition-colors"
|
||||
style={{ textDecoration: 'underline', textDecorationStyle: 'dotted' }}
|
||||
>
|
||||
角色中心
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
{session ? (
|
||||
<div className="flex items-center space-x-4">
|
||||
<span>你好, {userName}</span>
|
||||
<form action={handleSignOut}>
|
||||
<Button
|
||||
variant="outline"
|
||||
<div className="flex items-center space-x-4">
|
||||
{/* 桌面端登录状态 */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
{session && <span>你好, {userName}</span>}
|
||||
{session ? (
|
||||
<form action={serverSignOut}>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="submit"
|
||||
className="text-white border-white hover:bg-gray-700"
|
||||
className="text-red-300 border-white hover:bg-gray-700 hover:text-red-200 transition-all duration-300"
|
||||
>
|
||||
退出登录
|
||||
</Button>
|
||||
</form>
|
||||
</form>
|
||||
) : (
|
||||
<>
|
||||
<Button asChild variant="outline" className="text-emerald-400 border-emerald-400/30 hover:bg-gray-700 hover:text-emerald-300 transition-all duration-300">
|
||||
<Link href="/login">登录</Link>
|
||||
</Button>
|
||||
<Button asChild className="bg-emerald-600 hover:bg-emerald-700 transition-all duration-300 transform hover:-translate-y-0.5">
|
||||
<Link href="/register">注册</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex space-x-2">
|
||||
<Button asChild variant="outline" className="text-white border-white hover:bg-gray-700">
|
||||
|
||||
{/* 移动端菜单按钮 */}
|
||||
<MobileMenu session={session} userName={userName} serverSignOut={serverSignOut} handleProtectedLinkClick={handleProtectedLinkClick} />
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* 登录提示弹窗 */}
|
||||
{showLoginPrompt && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 animate-fadeIn"
|
||||
onClick={() => setShowLoginPrompt(false)}
|
||||
>
|
||||
<div
|
||||
className="bg-white/95 dark:bg-gray-800/95 backdrop-blur-md rounded-xl shadow-2xl p-8 max-w-md w-full mx-4 transform transition-all duration-300 animate-slideUp"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 className="text-xl font-bold text-gray-900 dark:text-white mb-4 text-center">需要登录</h3>
|
||||
<p className="text-gray-700 dark:text-gray-300 mb-6 text-center">
|
||||
请先登录以访问此功能。
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl px-6 py-2.5 shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1"
|
||||
onClick={() => setShowLoginPrompt(false)}
|
||||
>
|
||||
<Link href="/login">登录</Link>
|
||||
</Button>
|
||||
<Button asChild className="bg-green-600 hover:bg-green-700">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
className="border-emerald-600 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/10 rounded-xl px-6 py-2.5 shadow-md hover:shadow-lg transition-all duration-300 transform hover:-translate-y-1"
|
||||
onClick={() => setShowLoginPrompt(false)}
|
||||
>
|
||||
<Link href="/register">注册</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 直接导出客户端Navbar组件,会话获取将由上层组件处理
|
||||
export default Navbar;
|
||||
259
src/components/ServerInfoBubble.tsx
Normal file
259
src/components/ServerInfoBubble.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
// src/components/ServerInfoBubble.tsx
|
||||
'use client';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface ServerInfoBubbleProps {
|
||||
zIndex?: number;
|
||||
dragDistanceLimit?: number;
|
||||
}
|
||||
|
||||
export default function ServerInfoBubble({
|
||||
zIndex = 1000,
|
||||
dragDistanceLimit = 200
|
||||
}: ServerInfoBubbleProps) {
|
||||
// 服务器信息,这些将轮流显示
|
||||
const serverInfo = {
|
||||
players: {
|
||||
title: '在线玩家',
|
||||
content: '服务器内有 {playerCount} 名玩家正在游玩'
|
||||
},
|
||||
address: {
|
||||
title: '服务器地址',
|
||||
content: 'mc.hitwh.edu.cn:25565'
|
||||
},
|
||||
description: {
|
||||
title: '服务器介绍',
|
||||
content: '欢迎来到HITWH Minecraft服务器,这是一个充满创造力的社区'
|
||||
}
|
||||
};
|
||||
|
||||
const [currentInfo, setCurrentInfo] = useState<'players' | 'address' | 'description'>('players');
|
||||
const [displayText, setDisplayText] = useState('');
|
||||
const [cursorVisible, setCursorVisible] = useState(true);
|
||||
const [isTyping, setIsTyping] = useState(true);
|
||||
const [playerCount, setPlayerCount] = useState(0); // 模拟玩家数量
|
||||
|
||||
// 拖拽相关状态
|
||||
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const initialDragPos = useRef({ x: 0, y: 0 });
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 随机生成玩家数量(模拟从服务器获取)
|
||||
useEffect(() => {
|
||||
const updatePlayerCount = () => {
|
||||
setPlayerCount(Math.floor(Math.random() * 50) + 1); // 1-50随机玩家数
|
||||
};
|
||||
|
||||
updatePlayerCount();
|
||||
const countInterval = setInterval(updatePlayerCount, 30000); // 每30秒更新一次
|
||||
|
||||
return () => clearInterval(countInterval);
|
||||
}, []);
|
||||
|
||||
// 打字机效果
|
||||
useEffect(() => {
|
||||
if (!isTyping) return;
|
||||
|
||||
const fullText = serverInfo[currentInfo].content.replace('{playerCount}', playerCount.toString());
|
||||
let index = 0;
|
||||
|
||||
const typeInterval = setInterval(() => {
|
||||
if (index < fullText.length) {
|
||||
setDisplayText(fullText.slice(0, index + 1));
|
||||
index++;
|
||||
} else {
|
||||
clearInterval(typeInterval);
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, 50); // 打字速度
|
||||
|
||||
return () => clearInterval(typeInterval);
|
||||
}, [currentInfo, isTyping, playerCount]);
|
||||
|
||||
// 光标闪烁效果
|
||||
useEffect(() => {
|
||||
const cursorInterval = setInterval(() => {
|
||||
setCursorVisible(prev => !prev);
|
||||
}, 530);
|
||||
|
||||
return () => clearInterval(cursorInterval);
|
||||
}, []);
|
||||
|
||||
// 轮流显示不同信息
|
||||
useEffect(() => {
|
||||
const infoInterval = setInterval(() => {
|
||||
setIsTyping(true);
|
||||
setDisplayText('');
|
||||
|
||||
// 切换到下一个信息
|
||||
if (currentInfo === 'players') {
|
||||
setCurrentInfo('address');
|
||||
} else if (currentInfo === 'address') {
|
||||
setCurrentInfo('description');
|
||||
} else {
|
||||
setCurrentInfo('players');
|
||||
}
|
||||
}, 8000); // 每个信息显示8秒
|
||||
|
||||
return () => clearInterval(infoInterval);
|
||||
}, [currentInfo]);
|
||||
|
||||
// 动态计算右下角位置
|
||||
const [screenPosition, setScreenPosition] = useState({ bottom: 16, right: 16 });
|
||||
|
||||
// 监听窗口大小变化,重新计算位置
|
||||
useEffect(() => {
|
||||
const updatePosition = () => {
|
||||
setScreenPosition({ bottom: 16, right: 16 });
|
||||
};
|
||||
|
||||
// 初始计算位置
|
||||
updatePosition();
|
||||
|
||||
// 监听窗口大小变化
|
||||
window.addEventListener('resize', updatePosition);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updatePosition);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 开始拖拽
|
||||
const startDragging = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setIsDragging(true);
|
||||
initialDragPos.current = {
|
||||
x: e.clientX - dragPosition.x,
|
||||
y: e.clientY - dragPosition.y
|
||||
};
|
||||
|
||||
// 暂停动画并改变光标
|
||||
if (bubbleRef.current) {
|
||||
bubbleRef.current.style.animationPlayState = 'paused';
|
||||
bubbleRef.current.style.cursor = 'grabbing';
|
||||
}
|
||||
};
|
||||
|
||||
// 结束拖拽并返回原位
|
||||
const endDragging = () => {
|
||||
if (!isDragging) return;
|
||||
|
||||
setIsDragging(false);
|
||||
|
||||
// 恢复动画和光标
|
||||
if (bubbleRef.current) {
|
||||
bubbleRef.current.style.animationPlayState = 'running';
|
||||
bubbleRef.current.style.cursor = 'move';
|
||||
}
|
||||
|
||||
// 使用CSS动画返回原位
|
||||
if (bubbleRef.current) {
|
||||
bubbleRef.current.style.transition = 'transform 0.8s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
bubbleRef.current.style.transform = 'translate(0, 0)';
|
||||
|
||||
// 更新状态并清除过渡效果
|
||||
setTimeout(() => {
|
||||
setDragPosition({ x: 0, y: 0 });
|
||||
if (bubbleRef.current) {
|
||||
bubbleRef.current.style.transition = '';
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理拖拽移动
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
|
||||
const newX = e.clientX - initialDragPos.current.x;
|
||||
const newY = e.clientY - initialDragPos.current.y;
|
||||
|
||||
// 限制拖拽范围
|
||||
const limitedX = Math.max(-dragDistanceLimit, Math.min(dragDistanceLimit, newX));
|
||||
const limitedY = Math.max(-dragDistanceLimit, Math.min(dragDistanceLimit, newY));
|
||||
|
||||
// 直接操作DOM进行拖动,避免频繁的状态更新
|
||||
if (bubbleRef.current) {
|
||||
bubbleRef.current.style.transform = `translate(${limitedX}px, ${limitedY}px)`;
|
||||
}
|
||||
|
||||
// 同时更新状态以便组件状态一致
|
||||
setDragPosition({ x: limitedX, y: limitedY });
|
||||
};
|
||||
|
||||
// 设置全局鼠标事件监听
|
||||
useEffect(() => {
|
||||
if (isDragging) {
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', endDragging);
|
||||
document.addEventListener('mouseleave', endDragging);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', endDragging);
|
||||
document.removeEventListener('mouseleave', endDragging);
|
||||
};
|
||||
}
|
||||
}, [isDragging, dragDistanceLimit]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className="fixed flex flex-col sm:flex-row gap-4 animate-float cursor-move"
|
||||
style={{
|
||||
zIndex,
|
||||
bottom: `${screenPosition.bottom}px`,
|
||||
right: `${screenPosition.right}px`,
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
pointerEvents: 'all',
|
||||
transform: `translate(${dragPosition.x}px, ${dragPosition.y}px)`
|
||||
}}
|
||||
onMouseDown={startDragging}
|
||||
>
|
||||
{/* 服务器头像 */}
|
||||
<div className="relative">
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-emerald-400 to-teal-500 rounded-full blur-md opacity-75"></div>
|
||||
<div className="relative w-16 h-16 rounded-full bg-gray-900 overflow-hidden border-2 border-white dark:border-gray-800 shadow-lg">
|
||||
<div className="w-full h-full flex items-center justify-center bg-emerald-600 text-white font-bold text-xl">
|
||||
服
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 对话框气泡 */}
|
||||
<div className="relative bg-white dark:bg-gray-800 rounded-2xl p-4 max-w-xs shadow-xl border border-emerald-200 dark:border-emerald-900/30">
|
||||
{/* 气泡尾部 */}
|
||||
<div className="absolute top-1/2 transform -translate-y-1/2 w-4 h-4 bg-white dark:bg-gray-800 border-t border-l border-emerald-200 dark:border-emerald-900/30 left-0 -translate-x-2 rotate-45"></div>
|
||||
|
||||
{/* 气泡内容 */}
|
||||
<div className="font-medium text-emerald-600 dark:text-emerald-400 mb-2">
|
||||
{serverInfo[currentInfo].title}
|
||||
</div>
|
||||
<div className="text-sm text-gray-700 dark:text-gray-300 relative min-h-[2rem]">
|
||||
{displayText}
|
||||
<span className={`inline-block w-2 h-4 ml-0.5 bg-gray-500 dark:bg-gray-400 transition-opacity ${cursorVisible && isTyping ? 'opacity-100' : 'opacity-0'}`}></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 添加浮动动画样式
|
||||
export const ServerInfoBubbleStyles = `
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}`;
|
||||
@@ -1,9 +1,11 @@
|
||||
// src/components/auth/AuthForm.tsx
|
||||
// 认证表单组件 - 包含登录和注册功能,同时提供测试账号信息
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { signIn } from 'next-auth/react';
|
||||
|
||||
interface AuthFormProps {
|
||||
type: 'login' | 'register';
|
||||
@@ -20,12 +22,30 @@ export default function AuthForm({ type }: AuthFormProps) {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
|
||||
// 这里应该调用认证API
|
||||
console.log('提交表单', { username, password, email, minecraftUsername });
|
||||
|
||||
// 模拟API请求
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
setIsLoading(false);
|
||||
try {
|
||||
if (type === 'login') {
|
||||
// 使用next-auth的signIn功能进行登录
|
||||
const result = await signIn('credentials', {
|
||||
username: username || email, // 使用username或email作为用户名字段
|
||||
email: email, // 传递email字段
|
||||
password,
|
||||
redirect: true, // 登录成功后自动重定向
|
||||
callbackUrl: '/user-home' // 指定重定向目标
|
||||
});
|
||||
|
||||
console.log('登录结果:', result);
|
||||
} else {
|
||||
// 注册逻辑可以在这里实现
|
||||
console.log('注册信息', { username, password, email, minecraftUsername });
|
||||
// 模拟注册请求
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('认证失败:', error);
|
||||
// 这里可以添加错误处理逻辑
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -35,11 +55,24 @@ export default function AuthForm({ type }: AuthFormProps) {
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
required
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={type === 'login' ? "用户名或邮箱" : "输入用户名"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{type === 'login' && (
|
||||
<div>
|
||||
<Label htmlFor="email">电子邮箱(可选)</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="或直接输入邮箱登录"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Label htmlFor="password">密码</Label>
|
||||
<Input
|
||||
@@ -79,6 +112,14 @@ export default function AuthForm({ type }: AuthFormProps) {
|
||||
<Button type="submit" className="w-full" disabled={isLoading}>
|
||||
{isLoading ? '处理中...' : type === 'login' ? '登录' : '注册'}
|
||||
</Button>
|
||||
|
||||
{type === 'login' && (
|
||||
<div className="mt-4 p-3 bg-emerald-50 dark:bg-emerald-900/20 rounded-lg text-sm border border-emerald-200 dark:border-emerald-800">
|
||||
<div className="font-medium mb-1 text-emerald-700 dark:text-emerald-400">测试账号(开发环境)</div>
|
||||
<div>用户名: <span className="font-mono bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded">test</span></div>
|
||||
<div>密码: <span className="font-mono bg-white dark:bg-gray-800 px-1.5 py-0.5 rounded">test</span></div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
81
src/components/skins/Canvas2DSkinPreview.tsx
Normal file
81
src/components/skins/Canvas2DSkinPreview.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
// Canvas 2D皮肤预览组件 - 只渲染头部正脸
|
||||
'use client';
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
|
||||
interface Canvas2DSkinPreviewProps {
|
||||
skinUrl: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const Canvas2DSkinPreview: React.FC<Canvas2DSkinPreviewProps> = ({
|
||||
skinUrl,
|
||||
size = 128,
|
||||
className = ''
|
||||
}) => {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// 设置canvas尺寸
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
|
||||
// 清空canvas
|
||||
ctx.clearRect(0, 0, size, size);
|
||||
|
||||
// 创建图像对象加载皮肤
|
||||
const skinImage = new Image();
|
||||
skinImage.src = skinUrl;
|
||||
skinImage.crossOrigin = 'anonymous'; // 确保可以跨域访问
|
||||
|
||||
skinImage.onload = () => {
|
||||
// 确保使用像素化渲染
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
|
||||
// 皮肤头部正脸坐标(64x64皮肤)
|
||||
const sourceX = 8;
|
||||
const sourceY = 8;
|
||||
const sourceSize = 8;
|
||||
|
||||
// 计算缩放比例
|
||||
const scale = size / sourceSize;
|
||||
|
||||
// 绘制皮肤头部正脸(像素化渲染)
|
||||
ctx.drawImage(
|
||||
skinImage,
|
||||
sourceX, // 源图像的x坐标
|
||||
sourceY, // 源图像的y坐标
|
||||
sourceSize, // 源图像的宽度
|
||||
sourceSize, // 源图像的高度
|
||||
0, // 目标canvas的x坐标
|
||||
0, // 目标canvas的y坐标
|
||||
size, // 目标canvas的宽度
|
||||
size // 目标canvas的高度
|
||||
);
|
||||
};
|
||||
|
||||
// 错误处理
|
||||
skinImage.onerror = () => {
|
||||
console.error('无法加载皮肤图片:', skinUrl);
|
||||
// 绘制一个默认的灰色方块
|
||||
ctx.fillStyle = '#cccccc';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
};
|
||||
}, [skinUrl, size]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`image-rendering-pixelated ${className}`}
|
||||
style={{ imageRendering: 'pixelated' }} // 确保所有浏览器都使用像素化渲染
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Canvas2DSkinPreview;
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/components/skins/SkinGrid.tsx
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import Canvas2DSkinPreview from './Canvas2DSkinPreview';
|
||||
|
||||
interface Skin {
|
||||
id: string;
|
||||
@@ -10,19 +11,34 @@ interface Skin {
|
||||
|
||||
export default function SkinGrid({ skins }: { skins: Skin[] }) {
|
||||
if (skins.length === 0) {
|
||||
return <p>你还没有上传任何皮肤</p>;
|
||||
return null; // 空状态由父组件处理
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
{skins.map((skin) => (
|
||||
<Link key={skin.id} href={`/skins/${skin.id}`}>
|
||||
<Card className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<CardTitle>{skin.name}</CardTitle>
|
||||
<Link
|
||||
key={skin.id}
|
||||
href={`/skins/${skin.id}`}
|
||||
className="block transform transition-all duration-300 hover:-translate-y-1"
|
||||
>
|
||||
<Card className="h-full border border-gray-100 dark:border-gray-700 overflow-hidden group">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-emerald-50 to-teal-50 dark:from-emerald-900/5 dark:to-teal-900/5 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
|
||||
<div className="aspect-square bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4 relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-gray-50 to-gray-100 dark:from-gray-900 dark:to-gray-800 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
<Canvas2DSkinPreview
|
||||
skinUrl={`/test-skin.png`}
|
||||
size={128}
|
||||
className="max-w-full max-h-full relative z-10 transition-transform duration-500 group-hover:scale-110"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<CardHeader className="relative z-10 pb-2">
|
||||
<CardTitle className="text-lg font-bold text-gray-800 dark:text-white">{skin.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-gray-500">创建于: {skin.createdAt}</p>
|
||||
<CardContent className="relative z-10">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">上传于: {skin.createdAt}</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
// src/components/skins/SkinUploader.tsx
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function SkinUploader({ file }: { file: File }) {
|
||||
const imageUrl = URL.createObjectURL(file);
|
||||
|
||||
return (
|
||||
<div className="border rounded-md p-2 bg-gray-50">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt="皮肤预览"
|
||||
width={128}
|
||||
height={64}
|
||||
className="object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
// src/components/skins/SkinViewer3D.tsx
|
||||
//这部分还没写完!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
'use client';
|
||||
import { extend, Canvas } from '@react-three/fiber';
|
||||
import { OrbitControls, useTexture } from '@react-three/drei';
|
||||
import { Suspense } from 'react';
|
||||
import * as THREE from 'three';
|
||||
|
||||
// 显式扩展 Three.js 类
|
||||
extend({
|
||||
Mesh: THREE.Mesh,
|
||||
BoxGeometry: THREE.BoxGeometry,
|
||||
MeshStandardMaterial: THREE.MeshStandardMaterial,
|
||||
AmbientLight: THREE.AmbientLight,
|
||||
SpotLight: THREE.SpotLight,
|
||||
MeshBasicMaterial: THREE.MeshBasicMaterial
|
||||
});
|
||||
|
||||
interface SkinModelProps {
|
||||
skinUrl: string;
|
||||
}
|
||||
|
||||
const SkinModel: React.FC<SkinModelProps> = ({ skinUrl }) => {
|
||||
const texture = useTexture(skinUrl);
|
||||
|
||||
return (
|
||||
<mesh rotation={[0, Math.PI / 4, 0]}>
|
||||
<boxGeometry args={[1, 2, 0.5]} />
|
||||
<meshStandardMaterial map={texture} side={THREE.DoubleSide} />
|
||||
</mesh>
|
||||
);
|
||||
};
|
||||
|
||||
const FallbackModel: React.FC = () => (
|
||||
<mesh>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshBasicMaterial color="#cccccc" wireframe opacity={0.5} transparent />
|
||||
</mesh>
|
||||
);
|
||||
|
||||
interface SkinViewer3DProps {
|
||||
skinUrl: string;
|
||||
}
|
||||
|
||||
const SkinViewer3D: React.FC<SkinViewer3DProps> = ({ skinUrl }) => {
|
||||
return (
|
||||
<div className="w-full h-96 bg-gray-100 dark:bg-gray-800 rounded-lg">
|
||||
<Canvas
|
||||
camera={{
|
||||
position: [3, 1.5, 3],
|
||||
fov: 50,
|
||||
near: 0.1,
|
||||
far: 100
|
||||
}}
|
||||
shadows
|
||||
>
|
||||
<ambientLight intensity={0.7} />
|
||||
<spotLight
|
||||
position={[5, 8, 5]}
|
||||
angle={0.3}
|
||||
penumbra={1}
|
||||
intensity={1.5}
|
||||
castShadow
|
||||
shadow-mapSize-width={1024}
|
||||
shadow-mapSize-height={1024}
|
||||
/>
|
||||
<Suspense fallback={<FallbackModel />}>
|
||||
<SkinModel skinUrl={skinUrl} />
|
||||
<OrbitControls
|
||||
enableZoom={true}
|
||||
enablePan={true}
|
||||
minPolarAngle={Math.PI / 6}
|
||||
maxPolarAngle={Math.PI / 1.8}
|
||||
/>
|
||||
</Suspense>
|
||||
</Canvas>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SkinViewer3D;
|
||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border border-gray-200 dark:border-gray-700 py-6 shadow-lg relative transition-all duration-300 hover:-translate-y-1 hover:shadow-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
Reference in New Issue
Block a user