feat: 모드팩 배포 시스템 UI 구현
- 사용자 페이지 (/modpack): GitHub Release 스타일 UI - 버전별 카드, 변경 로그/콘텐츠 접이식 표시 - 애니메이션 적용 (AnimatePresence) - 관리자 콘솔: 모드팩 탭 추가 - 목록 조회, 업로드/수정 다이얼로그 - 모바일/데스크톱 분기 처리 - 모바일 바텀 네비게이션 - Sidebar: 모드팩 메뉴 추가 (월드맵 아래)
This commit is contained in:
parent
b952e73a6c
commit
81ed6ebf9c
4 changed files with 596 additions and 21 deletions
|
|
@ -8,6 +8,7 @@ import WorldsPage from './pages/WorldsPage';
|
||||||
import PlayersPage from './pages/PlayersPage';
|
import PlayersPage from './pages/PlayersPage';
|
||||||
import PlayerStatsPage from './pages/PlayerStatsPage';
|
import PlayerStatsPage from './pages/PlayerStatsPage';
|
||||||
import WorldMapPage from './pages/WorldMapPage';
|
import WorldMapPage from './pages/WorldMapPage';
|
||||||
|
import Modpack from './pages/Modpack';
|
||||||
import LoginPage from './pages/LoginPage';
|
import LoginPage from './pages/LoginPage';
|
||||||
import RegisterPage from './pages/RegisterPage';
|
import RegisterPage from './pages/RegisterPage';
|
||||||
import VerifyEmailPage from './pages/VerifyEmailPage';
|
import VerifyEmailPage from './pages/VerifyEmailPage';
|
||||||
|
|
@ -95,6 +96,7 @@ function App() {
|
||||||
<Route path="/players" element={<PageWrapper><PlayersPage isMobile={isMobile} /></PageWrapper>} />
|
<Route path="/players" element={<PageWrapper><PlayersPage isMobile={isMobile} /></PageWrapper>} />
|
||||||
<Route path="/player/:uuid/stats" element={<PageWrapper><PlayerStatsPage isMobile={isMobile} /></PageWrapper>} />
|
<Route path="/player/:uuid/stats" element={<PageWrapper><PlayerStatsPage isMobile={isMobile} /></PageWrapper>} />
|
||||||
<Route path="/worldmap" element={<PageWrapper><WorldMapPage isMobile={isMobile} /></PageWrapper>} />
|
<Route path="/worldmap" element={<PageWrapper><WorldMapPage isMobile={isMobile} /></PageWrapper>} />
|
||||||
|
<Route path="/modpack" element={<PageWrapper><Modpack /></PageWrapper>} />
|
||||||
<Route path="/profile" element={<PageWrapper><ProfilePage isMobile={isMobile} /></PageWrapper>} />
|
<Route path="/profile" element={<PageWrapper><ProfilePage isMobile={isMobile} /></PageWrapper>} />
|
||||||
<Route path="/admin" element={<PageWrapper><Admin isMobile={isMobile} /></PageWrapper>} />
|
<Route path="/admin" element={<PageWrapper><Admin isMobile={isMobile} /></PageWrapper>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
|
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
|
||||||
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2 } from 'lucide-react';
|
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2, Package } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { useAuth } from '../contexts/AuthContext';
|
import { useAuth } from '../contexts/AuthContext';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
@ -23,6 +23,7 @@ const Sidebar = ({ isMobile = false }) => {
|
||||||
{ path: '/players', icon: Users, label: '플레이어', matchPaths: ['/players', '/player/'] },
|
{ path: '/players', icon: Users, label: '플레이어', matchPaths: ['/players', '/player/'] },
|
||||||
{ path: '/worlds', icon: Globe, label: '월드 정보' },
|
{ path: '/worlds', icon: Globe, label: '월드 정보' },
|
||||||
{ path: '/worldmap', icon: Map, label: '월드맵' },
|
{ path: '/worldmap', icon: Map, label: '월드맵' },
|
||||||
|
{ path: '/modpack', icon: Package, label: '모드팩' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// 커스텀 활성 상태 확인 함수
|
// 커스텀 활성 상태 확인 함수
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import { useAuth } from '../contexts/AuthContext';
|
||||||
import {
|
import {
|
||||||
Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings,
|
Shield, ArrowLeft, ArrowDown, Loader2, Terminal, Users, Settings,
|
||||||
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
|
Send, Ban, UserX, Crown, Sun, Moon, Cloud, CloudRain, CloudLightning,
|
||||||
ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X
|
ChevronDown, FileText, Download, Trash2, Check, RefreshCw, Eye, X, Package, Upload, Plus, Pencil
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { io } from 'socket.io-client';
|
import { io } from 'socket.io-client';
|
||||||
|
|
@ -60,7 +60,7 @@ export default function Admin({ isMobile = false }) {
|
||||||
// 탭 상태 (URL 해시에서 초기값 로드)
|
// 탭 상태 (URL 해시에서 초기값 로드)
|
||||||
const getInitialTab = () => {
|
const getInitialTab = () => {
|
||||||
const hash = window.location.hash.replace('#', '');
|
const hash = window.location.hash.replace('#', '');
|
||||||
return ['console', 'players', 'settings'].includes(hash) ? hash : 'console';
|
return ['console', 'players', 'modpack', 'settings'].includes(hash) ? hash : 'console';
|
||||||
};
|
};
|
||||||
const [activeTab, setActiveTab] = useState(getInitialTab);
|
const [activeTab, setActiveTab] = useState(getInitialTab);
|
||||||
|
|
||||||
|
|
@ -122,6 +122,17 @@ export default function Admin({ isMobile = false }) {
|
||||||
memory: { used: 0, max: 0 },
|
memory: { used: 0, max: 0 },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 모드팩 관리 상태
|
||||||
|
const [showModpackDialog, setShowModpackDialog] = useState(false);
|
||||||
|
const [modpackDialogMode, setModpackDialogMode] = useState('upload'); // 'upload' | 'edit'
|
||||||
|
const [editingModpack, setEditingModpack] = useState(null);
|
||||||
|
const [modpackForm, setModpackForm] = useState({ version: '', changelog: '' });
|
||||||
|
const [modpacks, setModpacks] = useState([
|
||||||
|
{ id: 1, version: '1.2.0', name: '테스트 서버 모드팩', date: '2024-12-20', size: '15.0 MB' },
|
||||||
|
{ id: 2, version: '1.1.0', name: '테스트 서버 모드팩', date: '2024-12-15', size: '12.0 MB' },
|
||||||
|
{ id: 3, version: '1.0.0', name: '테스트 서버 모드팩', date: '2024-12-01', size: '8.0 MB' },
|
||||||
|
]);
|
||||||
|
|
||||||
// 권한 확인
|
// 권한 확인
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading) {
|
if (!loading) {
|
||||||
|
|
@ -793,11 +804,12 @@ export default function Admin({ isMobile = false }) {
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ id: 'console', label: '콘솔', icon: Terminal },
|
{ id: 'console', label: '콘솔', icon: Terminal },
|
||||||
{ id: 'players', label: '플레이어', icon: Users },
|
{ id: 'players', label: '플레이어', icon: Users },
|
||||||
|
{ id: 'modpack', label: '모드팩', icon: Package },
|
||||||
{ id: 'settings', label: '설정', icon: Settings },
|
{ id: 'settings', label: '설정', icon: Settings },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-8">
|
<div className={`${isMobile ? 'pb-24' : 'pb-8'}`}>
|
||||||
{/* 토스트 */}
|
{/* 토스트 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{toast && (
|
{toast && (
|
||||||
|
|
@ -835,23 +847,25 @@ export default function Admin({ isMobile = false }) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 탭 네비게이션 */}
|
{/* 탭 네비게이션 - 데스크톱 */}
|
||||||
<div className="flex gap-1 p-1 bg-zinc-900 rounded-xl mb-6">
|
{!isMobile && (
|
||||||
{tabs.map(tab => (
|
<div className="flex gap-1 p-1 bg-zinc-900 rounded-xl mb-6">
|
||||||
<button
|
{tabs.map(tab => (
|
||||||
key={tab.id}
|
<button
|
||||||
onClick={() => handleTabChange(tab.id)}
|
key={tab.id}
|
||||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg font-medium transition-all ${
|
onClick={() => handleTabChange(tab.id)}
|
||||||
activeTab === tab.id
|
className={`flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg font-medium transition-all ${
|
||||||
? 'bg-zinc-800 text-white'
|
activeTab === tab.id
|
||||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
|
? 'bg-zinc-800 text-white'
|
||||||
}`}
|
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
|
||||||
>
|
}`}
|
||||||
<tab.icon size={18} />
|
>
|
||||||
<span className={isMobile ? 'hidden sm:inline' : ''}>{tab.label}</span>
|
<tab.icon size={18} />
|
||||||
</button>
|
<span>{tab.label}</span>
|
||||||
))}
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 탭 콘텐츠 */}
|
{/* 탭 콘텐츠 */}
|
||||||
<AnimatePresence mode="wait">
|
<AnimatePresence mode="wait">
|
||||||
|
|
@ -1549,9 +1563,152 @@ export default function Admin({ isMobile = false }) {
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 모드팩 탭 */}
|
||||||
|
{activeTab === 'modpack' && (
|
||||||
|
<motion.div
|
||||||
|
key="modpack"
|
||||||
|
initial={{ opacity: 0, y: 10 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -10 }}
|
||||||
|
className="space-y-4"
|
||||||
|
>
|
||||||
|
<div className="bg-zinc-900 border border-zinc-800 rounded-2xl p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h3 className="text-white font-medium">📦 모드팩 관리</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setModpackDialogMode('upload');
|
||||||
|
setEditingModpack(null);
|
||||||
|
setModpackForm({ version: '', changelog: '' });
|
||||||
|
setShowModpackDialog(true);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors"
|
||||||
|
>
|
||||||
|
<Upload size={16} />
|
||||||
|
<span>업로드</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모드팩 목록 */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{modpacks.map((pack, i) => (
|
||||||
|
isMobile ? (
|
||||||
|
/* 모바일 레이아웃 - 세로 카드 */
|
||||||
|
<div key={pack.id} className="p-3 bg-zinc-800/50 rounded-xl">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{i === 0 && <span className="px-1.5 py-0.5 bg-mc-green/20 text-mc-green text-xs rounded">최신</span>}
|
||||||
|
<span className="text-zinc-400 text-sm">v{pack.version}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setModpackDialogMode('edit');
|
||||||
|
setEditingModpack(pack);
|
||||||
|
setModpackForm({ version: pack.version, changelog: '' });
|
||||||
|
setShowModpackDialog(true);
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-zinc-400 hover:text-blue-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`${pack.name} v${pack.version}을(를) 삭제하시겠습니까?`)) {
|
||||||
|
setModpacks(prev => prev.filter(p => p.id !== pack.id));
|
||||||
|
setToast('모드팩이 삭제되었습니다.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-1.5 text-zinc-400 hover:text-red-400 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-white font-medium mb-1">{pack.name}</p>
|
||||||
|
<p className="text-zinc-500 text-sm">{pack.date} · {pack.size}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* 데스크톱 레이아웃 - 가로 */
|
||||||
|
<div key={pack.id} className="flex items-center justify-between p-3 bg-zinc-800/50 rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${i === 0 ? 'bg-mc-green/20' : 'bg-zinc-700'}`}>
|
||||||
|
<Package size={18} className={i === 0 ? 'text-mc-green' : 'text-zinc-400'} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white font-medium">{pack.name}</span>
|
||||||
|
<span className="text-zinc-400">v{pack.version}</span>
|
||||||
|
{i === 0 && <span className="px-1.5 py-0.5 bg-mc-green/20 text-mc-green text-xs rounded">최신</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-zinc-500 text-sm">{pack.date} · {pack.size}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setModpackDialogMode('edit');
|
||||||
|
setEditingModpack(pack);
|
||||||
|
setModpackForm({ version: pack.version, changelog: '' });
|
||||||
|
setShowModpackDialog(true);
|
||||||
|
}}
|
||||||
|
className="p-2 text-zinc-400 hover:text-blue-400 transition-colors"
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Pencil size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`${pack.name} v${pack.version}을(를) 삭제하시겠습니까?`)) {
|
||||||
|
setModpacks(prev => prev.filter(p => p.id !== pack.id));
|
||||||
|
setToast('모드팩이 삭제되었습니다.');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="p-2 text-zinc-400 hover:text-red-400 transition-colors"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 */}
|
||||||
|
{modpacks.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<Package className="mx-auto text-zinc-600 mb-2" size={32} />
|
||||||
|
<p className="text-zinc-500 text-sm">등록된 모드팩이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* 모바일 바텀 네비게이션 */}
|
||||||
|
{isMobile && (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 bg-zinc-900/95 backdrop-blur-lg border-t border-zinc-800 px-2 pb-safe z-50">
|
||||||
|
<div className="flex">
|
||||||
|
{tabs.map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => handleTabChange(tab.id)}
|
||||||
|
className={`flex-1 flex flex-col items-center justify-center py-3 transition-colors ${
|
||||||
|
activeTab === tab.id ? 'text-mc-green' : 'text-zinc-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<tab.icon size={20} />
|
||||||
|
<span className="text-xs mt-1">{tab.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 플레이어 액션 다이얼로그 */}
|
{/* 플레이어 액션 다이얼로그 */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showPlayerDialog && selectedPlayer && (
|
{showPlayerDialog && selectedPlayer && (
|
||||||
|
|
@ -1774,6 +1931,87 @@ export default function Admin({ isMobile = false }) {
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 모드팩 업로드/수정 다이얼로그 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showModpackDialog && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-[100] p-4"
|
||||||
|
onClick={() => setShowModpackDialog(false)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{ scale: 0.9, opacity: 0 }}
|
||||||
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
exit={{ scale: 0.9, opacity: 0 }}
|
||||||
|
className="bg-zinc-900 border border-zinc-800 rounded-2xl p-6 max-w-md w-full max-h-[80vh] overflow-y-auto"
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-white text-lg font-bold mb-4">
|
||||||
|
{modpackDialogMode === 'upload' ? '📦 모드팩 업로드' : '✏️ 모드팩 수정'}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* 파일 선택 (업로드 모드에서만) */}
|
||||||
|
{modpackDialogMode === 'upload' && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-zinc-400 text-sm mb-2">파일 선택 (.mrpack)</label>
|
||||||
|
<div className="border-2 border-dashed border-zinc-700 rounded-xl p-6 text-center hover:border-mc-green/50 transition-colors cursor-pointer">
|
||||||
|
<Upload className="mx-auto text-zinc-500 mb-2" size={24} />
|
||||||
|
<p className="text-zinc-400 text-sm">클릭하여 파일 선택</p>
|
||||||
|
<p className="text-zinc-600 text-xs mt-1">또는 파일을 여기에 드래그</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 수정 모드에서 파일명 표시 */}
|
||||||
|
{modpackDialogMode === 'edit' && editingModpack && (
|
||||||
|
<div className="mb-4 p-3 bg-zinc-800 rounded-xl">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Package className="text-mc-green" size={20} />
|
||||||
|
<div>
|
||||||
|
<p className="text-white font-medium">{editingModpack.name} v{editingModpack.version}</p>
|
||||||
|
<p className="text-zinc-500 text-xs">{editingModpack.size}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 변경 로그 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-zinc-400 text-sm mb-2">변경 로그</label>
|
||||||
|
<textarea
|
||||||
|
value={modpackForm.changelog}
|
||||||
|
onChange={(e) => setModpackForm(prev => ({ ...prev, changelog: e.target.value }))}
|
||||||
|
placeholder="### 새로운 기능 - 기능 1 추가 - 기능 2 추가 ### 버그 수정 - 버그 수정"
|
||||||
|
rows={6}
|
||||||
|
className="w-full bg-zinc-800 rounded-xl p-3 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-mc-green/50 resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowModpackDialog(false)}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-white rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setToast(modpackDialogMode === 'upload' ? '모드팩이 업로드되었습니다.' : '모드팩이 수정되었습니다.');
|
||||||
|
setShowModpackDialog(false);
|
||||||
|
}}
|
||||||
|
className="flex-1 px-4 py-2.5 bg-mc-green hover:bg-mc-green/80 text-white rounded-xl font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{modpackDialogMode === 'upload' ? '업로드' : '저장'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
334
frontend/src/pages/Modpack.jsx
Normal file
334
frontend/src/pages/Modpack.jsx
Normal file
|
|
@ -0,0 +1,334 @@
|
||||||
|
/**
|
||||||
|
* 모드팩 페이지 - GitHub Release 스타일
|
||||||
|
*/
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import { Download, Package, ChevronDown, ChevronUp, Calendar, HardDrive, Gamepad2, Box, Image } from 'lucide-react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
|
||||||
|
// 더미 데이터
|
||||||
|
const DUMMY_MODPACKS = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
version: '1.2.0',
|
||||||
|
name: '테스트 서버 모드팩',
|
||||||
|
minecraftVersion: '1.21.1',
|
||||||
|
modLoader: 'NeoForge',
|
||||||
|
modLoaderVersion: '21.1.213',
|
||||||
|
changelog: `### 새로운 기능
|
||||||
|
- Create 모드 추가
|
||||||
|
- JEI (Just Enough Items) 추가
|
||||||
|
- Jade 모드 추가
|
||||||
|
|
||||||
|
### 버그 수정
|
||||||
|
- 일부 텍스처 누락 문제 해결`,
|
||||||
|
fileSize: 15728640, // 15MB
|
||||||
|
downloadCount: 42,
|
||||||
|
createdAt: '2024-12-20T10:30:00Z',
|
||||||
|
contents: {
|
||||||
|
mods: [
|
||||||
|
{ name: 'Create', version: '0.5.1' },
|
||||||
|
{ name: 'Just Enough Items', version: '15.2.0' },
|
||||||
|
{ name: 'Jade', version: '11.7.1' },
|
||||||
|
{ name: 'JourneyMap', version: '5.9.18' },
|
||||||
|
{ name: 'Sodium', version: '0.5.8' },
|
||||||
|
],
|
||||||
|
resourcepacks: [
|
||||||
|
{ name: 'Faithful 32x', version: '1.21' },
|
||||||
|
],
|
||||||
|
shaderpacks: [
|
||||||
|
{ name: 'Complementary Shaders', version: '5.2.1' },
|
||||||
|
{ name: 'BSL Shaders', version: '8.2.09' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
version: '1.1.0',
|
||||||
|
name: '테스트 서버 모드팩',
|
||||||
|
minecraftVersion: '1.21.1',
|
||||||
|
modLoader: 'NeoForge',
|
||||||
|
modLoaderVersion: '21.1.200',
|
||||||
|
changelog: `### 변경사항
|
||||||
|
- JourneyMap 추가
|
||||||
|
- Sodium 성능 모드 추가`,
|
||||||
|
fileSize: 12582912, // 12MB
|
||||||
|
downloadCount: 128,
|
||||||
|
createdAt: '2024-12-15T14:00:00Z',
|
||||||
|
contents: {
|
||||||
|
mods: [
|
||||||
|
{ name: 'Just Enough Items', version: '15.1.0' },
|
||||||
|
{ name: 'JourneyMap', version: '5.9.17' },
|
||||||
|
{ name: 'Sodium', version: '0.5.7' },
|
||||||
|
],
|
||||||
|
resourcepacks: [],
|
||||||
|
shaderpacks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
version: '1.0.0',
|
||||||
|
name: '테스트 서버 모드팩',
|
||||||
|
minecraftVersion: '1.21.1',
|
||||||
|
modLoader: 'NeoForge',
|
||||||
|
modLoaderVersion: '21.1.180',
|
||||||
|
changelog: `### 최초 릴리즈
|
||||||
|
- 기본 모드 구성`,
|
||||||
|
fileSize: 8388608, // 8MB
|
||||||
|
downloadCount: 256,
|
||||||
|
createdAt: '2024-12-01T09:00:00Z',
|
||||||
|
contents: {
|
||||||
|
mods: [
|
||||||
|
{ name: 'Just Enough Items', version: '15.0.0' },
|
||||||
|
],
|
||||||
|
resourcepacks: [],
|
||||||
|
shaderpacks: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// 파일 크기 포맷
|
||||||
|
const formatFileSize = (bytes) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1073741824) return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / 1073741824).toFixed(2)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 날짜 포맷
|
||||||
|
const formatDate = (dateString) => {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
return date.toLocaleDateString('ko-KR', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 모드팩 카드 컴포넌트
|
||||||
|
const ModpackCard = ({ modpack, isLatest }) => {
|
||||||
|
const [showChangelog, setShowChangelog] = useState(isLatest);
|
||||||
|
const [showContents, setShowContents] = useState(false);
|
||||||
|
|
||||||
|
const totalMods = modpack.contents.mods.length;
|
||||||
|
const totalResourcepacks = modpack.contents.resourcepacks.length;
|
||||||
|
const totalShaderpacks = modpack.contents.shaderpacks.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-zinc-900 border rounded-2xl overflow-hidden ${isLatest ? 'border-mc-green' : 'border-zinc-800'}`}>
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2.5 rounded-xl ${isLatest ? 'bg-mc-green/20' : 'bg-zinc-800'}`}>
|
||||||
|
<Package className={isLatest ? 'text-mc-green' : 'text-zinc-400'} size={24} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<h3 className="text-xl font-bold text-white">v{modpack.version}</h3>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="px-2 py-0.5 bg-mc-green/20 text-mc-green text-xs font-medium rounded-full">
|
||||||
|
최신
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-zinc-400 mt-0.5">{modpack.name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 다운로드 버튼 */}
|
||||||
|
<button className="flex items-center gap-2 px-4 py-2.5 bg-mc-green hover:bg-mc-green/80 text-white font-medium rounded-xl transition-colors">
|
||||||
|
<Download size={18} />
|
||||||
|
<span>다운로드</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 메타 정보 */}
|
||||||
|
<div className="flex flex-wrap gap-4 mt-4 text-sm text-zinc-400">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Gamepad2 size={14} />
|
||||||
|
<span>MC {modpack.minecraftVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Box size={14} />
|
||||||
|
<span>{modpack.modLoader} {modpack.modLoaderVersion}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<HardDrive size={14} />
|
||||||
|
<span>{formatFileSize(modpack.fileSize)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar size={14} />
|
||||||
|
<span>{formatDate(modpack.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 포함 콘텐츠 요약 */}
|
||||||
|
<div className="flex gap-3 mt-4">
|
||||||
|
{totalMods > 0 && (
|
||||||
|
<span className="px-2.5 py-1 bg-blue-500/20 text-blue-400 text-xs rounded-lg">
|
||||||
|
모드 {totalMods}개
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalResourcepacks > 0 && (
|
||||||
|
<span className="px-2.5 py-1 bg-purple-500/20 text-purple-400 text-xs rounded-lg">
|
||||||
|
리소스팩 {totalResourcepacks}개
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalShaderpacks > 0 && (
|
||||||
|
<span className="px-2.5 py-1 bg-orange-500/20 text-orange-400 text-xs rounded-lg">
|
||||||
|
셰이더 {totalShaderpacks}개
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 변경 로그 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowChangelog(!showChangelog)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-400 text-sm transition-colors border-t border-zinc-800"
|
||||||
|
>
|
||||||
|
<span>변경 로그</span>
|
||||||
|
{showChangelog ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 변경 로그 내용 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showChangelog && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
||||||
|
<div className="prose prose-sm prose-invert max-w-none">
|
||||||
|
{modpack.changelog.split('\n').map((line, i) => {
|
||||||
|
if (line.startsWith('###')) {
|
||||||
|
return <h4 key={i} className="text-white font-semibold mt-3 mb-2 text-sm">{line.replace('### ', '')}</h4>;
|
||||||
|
}
|
||||||
|
if (line.startsWith('- ')) {
|
||||||
|
return <p key={i} className="text-zinc-400 text-sm my-1 pl-3">• {line.replace('- ', '')}</p>;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
|
||||||
|
{/* 포함 콘텐츠 토글 */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowContents(!showContents)}
|
||||||
|
className="w-full flex items-center justify-between px-5 py-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-400 text-sm transition-colors border-t border-zinc-800"
|
||||||
|
>
|
||||||
|
<span>포함된 콘텐츠</span>
|
||||||
|
{showContents ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 포함 콘텐츠 내용 */}
|
||||||
|
<AnimatePresence>
|
||||||
|
{showContents && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ height: 0, opacity: 0 }}
|
||||||
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
|
exit={{ height: 0, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.2, ease: 'easeInOut' }}
|
||||||
|
className="overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="px-5 py-4 bg-zinc-800/30 border-t border-zinc-800">
|
||||||
|
{/* 모드 */}
|
||||||
|
{modpack.contents.mods.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
||||||
|
<Box size={14} className="text-blue-400" />
|
||||||
|
모드
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{modpack.contents.mods.map((mod, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||||
|
<span className="text-zinc-300 text-sm">{mod.name}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">{mod.version}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 리소스팩 */}
|
||||||
|
{modpack.contents.resourcepacks.length > 0 && (
|
||||||
|
<div className="mb-4">
|
||||||
|
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
||||||
|
<Image size={14} className="text-purple-400" />
|
||||||
|
리소스팩
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{modpack.contents.resourcepacks.map((pack, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||||
|
<span className="text-zinc-300 text-sm">{pack.name}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">{pack.version}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 셰이더 */}
|
||||||
|
{modpack.contents.shaderpacks.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h4 className="text-white font-medium text-sm mb-2 flex items-center gap-2">
|
||||||
|
<span className="text-orange-400">✨</span>
|
||||||
|
셰이더
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
{modpack.contents.shaderpacks.map((shader, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between px-3 py-2 bg-zinc-800 rounded-lg">
|
||||||
|
<span className="text-zinc-300 text-sm">{shader.name}</span>
|
||||||
|
<span className="text-zinc-500 text-xs">{shader.version}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Modpack() {
|
||||||
|
const modpacks = DUMMY_MODPACKS;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-mc-dark p-4 sm:p-6">
|
||||||
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{/* 헤더 */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||||
|
<Package className="text-mc-green" />
|
||||||
|
모드팩
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400 mt-1">서버 접속에 필요한 모드팩을 다운로드하세요</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 모드팩 목록 */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
{modpacks.map((modpack, index) => (
|
||||||
|
<ModpackCard key={modpack.id} modpack={modpack} isLatest={index === 0} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 빈 상태 (모드팩이 없을 때) */}
|
||||||
|
{modpacks.length === 0 && (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<Package className="mx-auto text-zinc-600 mb-4" size={48} />
|
||||||
|
<p className="text-zinc-400">등록된 모드팩이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue