Files
carrotskin/src/components/SkinDetailModal.tsx
lan a9ff72a9bf feat: 增加用户皮肤管理功能和Yggdrasil密码重置
- 在用户资料页面添加皮肤选择和管理功能,支持上传、配置和移除皮肤
- 实现Yggdrasil密码重置功能,用户可生成新密码并显示
- 优化皮肤展示和选择界面,增强用户体验
- 更新SkinViewer组件,支持跑步和跳跃动画
2025-12-04 22:33:46 +08:00

316 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { XMarkIcon, PlayIcon, PauseIcon, ArrowPathIcon, ForwardIcon} from '@heroicons/react/24/outline';
import SkinViewer from './SkinViewer';
interface SkinDetailModalProps {
isOpen: boolean;
onClose: () => void;
texture: {
id: number;
name: string;
url: string;
type: 'SKIN' | 'CAPE';
is_slim?: boolean;
description?: string;
favorite_count?: number;
download_count?: number;
created_at?: string;
uploader?: {
username: string;
};
} | null;
}
export default function SkinDetailModal({ isOpen, onClose, texture }: SkinDetailModalProps) {
const [isPlaying, setIsPlaying] = useState(false);
const [currentAnimation, setCurrentAnimation] = useState<'idle' | 'walking' | 'running' | 'jumping'>('idle');
const [autoRotate, setAutoRotate] = useState(true);
const [rotation, setRotation] = useState(true);
// 重置状态当对话框关闭时
useEffect(() => {
if (!isOpen) {
setIsPlaying(false);
setCurrentAnimation('idle');
setAutoRotate(true);
setRotation(true);
}
}, [isOpen]);
// 键盘事件处理
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!isOpen) return;
switch (e.key) {
case 'Escape':
onClose();
break;
case ' ':
e.preventDefault();
setIsPlaying(!isPlaying);
break;
case '1':
setCurrentAnimation('idle');
break;
case '2':
setCurrentAnimation('walking');
break;
case '3':
setCurrentAnimation('running');
break;
case '4':
setCurrentAnimation('jumping');
break;
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isPlaying]);
if (!texture) return null;
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
onClick={onClose}
>
<motion.div
initial={{ scale: 0.9, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.9, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="relative w-full max-w-6xl h-[90vh] bg-white/95 dark:bg-gray-800/95 rounded-2xl shadow-2xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 bg-gradient-to-r from-white/90 to-gray-50/90 dark:from-gray-800/90 dark:to-gray-700/90 backdrop-blur-md border-b border-gray-200/50 dark:border-gray-600/50 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
{texture.name}
</h2>
<div className="flex items-center space-x-2">
<span className={`px-3 py-1 text-sm rounded-full font-medium ${
texture.type === 'SKIN'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300'
}`}>
{texture.type === 'SKIN' ? '皮肤' : '披风'}
</span>
{texture.is_slim && (
<span className="px-3 py-1 bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300 text-sm rounded-full font-medium">
</span>
)}
</div>
</div>
<motion.button
onClick={onClose}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all duration-200"
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
>
<XMarkIcon className="w-6 h-6" />
</motion.button>
</div>
</div>
<div className="flex h-full pt-20">
{/* 3D 预览区域 */}
<div className="flex-1 flex items-center justify-center p-8">
<div className="w-full h-full max-w-2xl max-h-2xl">
<SkinViewer
skinUrl={texture.url}
isSlim={texture.is_slim}
width={600}
height={600}
className="w-full h-full rounded-xl shadow-lg"
autoRotate={autoRotate}
walking={currentAnimation === 'walking'}
running={currentAnimation === 'running'}
jumping={currentAnimation === 'jumping'}
rotation={rotation}
/>
</div>
</div>
{/* 控制面板 */}
<div className="w-80 bg-gray-50/80 dark:bg-gray-900/80 backdrop-blur-md border-l border-gray-200/50 dark:border-gray-600/50 p-6 space-y-6 overflow-y-auto">
{/* 动画控制 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<PlayIcon className="w-5 h-5 mr-2" />
</h3>
<div className="grid grid-cols-2 gap-2">
<motion.button
onClick={() => setCurrentAnimation('idle')}
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
currentAnimation === 'idle'
? 'bg-orange-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
onClick={() => setCurrentAnimation('walking')}
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
currentAnimation === 'walking'
? 'bg-orange-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
onClick={() => setCurrentAnimation('running')}
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
currentAnimation === 'running'
? 'bg-orange-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
<motion.button
onClick={() => setCurrentAnimation('jumping')}
className={`p-3 rounded-lg text-sm font-medium transition-all duration-200 ${
currentAnimation === 'jumping'
? 'bg-orange-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
</motion.button>
</div>
</div>
{/* 视角控制 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<ArrowPathIcon className="w-5 h-5 mr-2" />
</h3>
<div className="space-y-3">
<motion.button
onClick={() => setAutoRotate(!autoRotate)}
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
autoRotate
? 'bg-blue-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ArrowPathIcon className="w-4 h-4 mr-2" />
{autoRotate ? '停止旋转' : '自动旋转'}
</motion.button>
<motion.button
onClick={() => setRotation(!rotation)}
className={`w-full p-3 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center ${
rotation
? 'bg-green-500 text-white shadow-lg'
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600 border border-gray-200 dark:border-gray-600'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ForwardIcon className="w-4 h-4 mr-2" />
{rotation ? '禁用控制' : '启用控制'}
</motion.button>
</div>
</div>
{/* 皮肤信息 */}
<div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white flex items-center">
<span className="w-5 h-5 mr-2">📋</span>
</h3>
<div className="bg-white/50 dark:bg-gray-800/50 rounded-lg p-4 space-y-3">
{texture.description && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2"></p>
<p className="text-sm text-gray-900 dark:text-white">{texture.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-gray-600 dark:text-gray-400"></p>
<p className="font-semibold text-gray-900 dark:text-white">{texture.favorite_count || 0}</p>
</div>
<div>
<p className="text-gray-600 dark:text-gray-400"></p>
<p className="font-semibold text-gray-900 dark:text-white">{texture.download_count || 0}</p>
</div>
</div>
{texture.uploader && (
<div>
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="font-semibold text-gray-900 dark:text-white">{texture.uploader.username}</p>
</div>
)}
{texture.created_at && (
<div>
<p className="text-gray-600 dark:text-gray-400 text-sm"></p>
<p className="font-semibold text-gray-900 dark:text-white">
{new Date(texture.created_at).toLocaleDateString('zh-CN')}
</p>
</div>
)}
</div>
</div>
{/* 快捷键提示 */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-300 mb-2 flex items-center">
<span className="w-4 h-4 mr-2"></span>
</h4>
<div className="text-xs text-blue-700 dark:text-blue-400 space-y-1">
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded"></kbd> /</p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">1</kbd> </p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">2</kbd> </p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">3</kbd> </p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">4</kbd> </p>
<p><kbd className="px-1 py-0.5 bg-white/50 dark:bg-gray-800/50 rounded">ESC</kbd> </p>
</div>
</div>
</div>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}