관리자 대시보드 리디자인 및 메뉴/이미지 관리 페이지 추가
- 관리자 페이지 카드형 메뉴 구조로 개편 (DB 연동 준비) - 메이플스토리 폰트, 단풍잎 favicon 적용 - 헤더 디자인 개선 (백드롭 블러, 단풍잎 로고) - 홈 페이지를 메뉴 동적 로드 형태로 변경 - 보스 계산기 페이지 제거 (DB 기반으로 재구축 예정) - 이미지/메뉴 관리 페이지 라우트 추가 (placeholder) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6c51e7d94d
commit
72ff284f20
14 changed files with 331 additions and 475 deletions
|
|
@ -2,9 +2,13 @@
|
||||||
<html lang="ko">
|
<html lang="ko">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>메이플스토리 도우미</title>
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;700;900&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://cdn.jsdelivr.net/gh/fonts-archive/Maplestory/Maplestory.css" rel="stylesheet" />
|
||||||
|
<title>메이플스토리 유틸리티</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
BIN
frontend/public/favicon.ico
Normal file
BIN
frontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 9.3 KiB |
|
|
@ -1,16 +1,21 @@
|
||||||
import { Routes, Route } from 'react-router-dom'
|
import { Routes, Route } from 'react-router-dom'
|
||||||
import Layout from './components/Layout'
|
import Layout from './components/Layout'
|
||||||
import Home from './pages/Home'
|
import Home from './pages/Home'
|
||||||
import BossPage from './features/boss/BossPage'
|
import AdminLayout from './features/admin/AdminLayout'
|
||||||
import Admin from './pages/Admin'
|
import AdminHome from './features/admin/AdminHome'
|
||||||
|
import AdminImages from './features/admin/AdminImages'
|
||||||
|
import AdminMenuForm from './features/admin/AdminMenuForm'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
<Route index element={<Home />} />
|
<Route index element={<Home />} />
|
||||||
<Route path="/boss" element={<BossPage />} />
|
<Route path="/admin" element={<AdminLayout />}>
|
||||||
<Route path="/admin" element={<Admin />} />
|
<Route index element={<AdminHome />} />
|
||||||
|
<Route path="images" element={<AdminImages />} />
|
||||||
|
<Route path="menus/new" element={<AdminMenuForm />} />
|
||||||
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,18 @@ import { Outlet, Link } from 'react-router-dom'
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-950 text-white">
|
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white">
|
||||||
<header className="border-b border-gray-800 px-6 py-4">
|
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md">
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between">
|
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
||||||
<Link to="/" className="text-xl font-bold">메이플스토리 도우미</Link>
|
<Link to="/" className="group flex items-center gap-2.5">
|
||||||
<nav className="flex items-center gap-6">
|
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
||||||
<Link to="/boss" className="text-gray-400 hover:text-white transition">보스 계산기</Link>
|
<span className="text-lg font-bold tracking-tight">
|
||||||
</nav>
|
메이플스토리 유틸리티
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="mx-auto max-w-5xl px-6 py-8">
|
<main className="mx-auto max-w-5xl px-6 py-10">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
10
frontend/src/features/admin/AdminBoss.jsx
Normal file
10
frontend/src/features/admin/AdminBoss.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
export default function AdminBoss() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="text-lg font-semibold">보스 수익 계산기 관리</h2>
|
||||||
|
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
|
||||||
|
준비 중
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
frontend/src/features/admin/AdminHome.jsx
Normal file
116
frontend/src/features/admin/AdminHome.jsx
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
function MenuCard({ menu }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={menu.url}
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-emerald-500/30 hover:from-emerald-500/5 hover:to-cyan-500/5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-2xl transition-all duration-500" />
|
||||||
|
|
||||||
|
<div className="relative flex items-start gap-4">
|
||||||
|
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
|
||||||
|
{menu.image_url ? (
|
||||||
|
<img src={menu.image_url} alt={menu.title} className="w-7 h-7 object-contain" />
|
||||||
|
) : (
|
||||||
|
menu.icon || '📋'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-white group-hover:text-emerald-300 transition">{menu.title}</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{menu.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-700 group-hover:text-emerald-400 group-hover:translate-x-1 transition-all duration-300">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddCard({ to, icon, label }) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to={to}
|
||||||
|
className="group flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 bg-white/[0.02] hover:bg-emerald-500/5 p-5 min-h-[112px] transition-all"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-full border border-white/10 group-hover:border-emerald-500/40 flex items-center justify-center text-gray-500 group-hover:text-emerald-400 transition">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-gray-500 group-hover:text-emerald-300 transition">{label}</span>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminHome() {
|
||||||
|
const [menus, setMenus] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// TODO: 백엔드 구현 후 실제 API 호출
|
||||||
|
api('/api/admin/menus')
|
||||||
|
.then(setMenus)
|
||||||
|
.catch(() => setMenus([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* 메뉴 섹션 */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">기능 관리</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">메뉴 항목을 추가하거나 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{loading ? (
|
||||||
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-28 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{menus.map((menu) => (
|
||||||
|
<MenuCard key={menu.id} menu={menu} />
|
||||||
|
))}
|
||||||
|
<AddCard to="/admin/menus/new" icon="+" label="메뉴 항목 추가" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 자원 관리 섹션 */}
|
||||||
|
<section className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">자원 관리</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">공용 이미지 등 사이트 자원을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Link
|
||||||
|
to="/admin/images"
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5 hover:border-cyan-500/30 hover:from-cyan-500/5 hover:to-blue-500/5 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-cyan-500/0 group-hover:bg-cyan-500/10 blur-2xl transition-all duration-500" />
|
||||||
|
<div className="relative flex items-start gap-4">
|
||||||
|
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-cyan-500/30 transition-all duration-300">
|
||||||
|
🖼️
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold group-hover:text-cyan-300 transition">이미지 관리</h3>
|
||||||
|
<p className="text-sm text-gray-400 mt-1 leading-relaxed">공용 이미지 업로드 및 관리</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-gray-700 group-hover:text-cyan-400 group-hover:translate-x-1 transition-all duration-300">
|
||||||
|
→
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/features/admin/AdminImages.jsx
Normal file
13
frontend/src/features/admin/AdminImages.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default function AdminImages() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">이미지 관리</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">공용 이미지를 업로드하고 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-12 text-center text-gray-500">
|
||||||
|
준비 중
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
73
frontend/src/features/admin/AdminLayout.jsx
Normal file
73
frontend/src/features/admin/AdminLayout.jsx
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useSearchParams, Outlet, Navigate, Link, useLocation } from 'react-router-dom'
|
||||||
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const [searchParams] = useSearchParams()
|
||||||
|
const [verified, setVerified] = useState(null)
|
||||||
|
const location = useLocation()
|
||||||
|
const isRoot = location.pathname === '/admin' || location.pathname === '/admin/'
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const keyFromUrl = searchParams.get('key')
|
||||||
|
const keyFromStorage = localStorage.getItem('maple-admin-key')
|
||||||
|
const key = keyFromUrl || keyFromStorage
|
||||||
|
|
||||||
|
if (!key) {
|
||||||
|
setVerified(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api('/api/admin/verify', { method: 'POST', body: { key } })
|
||||||
|
.then(() => {
|
||||||
|
localStorage.setItem('maple-admin-key', key)
|
||||||
|
setVerified(true)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
localStorage.removeItem('maple-admin-key')
|
||||||
|
setVerified(false)
|
||||||
|
})
|
||||||
|
}, [searchParams])
|
||||||
|
|
||||||
|
if (verified === null) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center pt-20">
|
||||||
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-8">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{!isRoot && (
|
||||||
|
<Link
|
||||||
|
to="/admin"
|
||||||
|
className="flex items-center justify-center w-9 h-9 rounded-lg border border-white/10 hover:border-white/20 hover:bg-white/5 text-gray-400 hover:text-white transition"
|
||||||
|
aria-label="뒤로"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-emerald-400 uppercase tracking-wider mb-1">Admin</div>
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight">관리자</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { localStorage.removeItem('maple-admin-key'); setVerified(false) }}
|
||||||
|
className="text-sm text-gray-500 hover:text-gray-300 transition"
|
||||||
|
>
|
||||||
|
로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
frontend/src/features/admin/AdminMenuForm.jsx
Normal file
13
frontend/src/features/admin/AdminMenuForm.jsx
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
export default function AdminMenuForm() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">메뉴 항목 추가</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">새 기능 카드를 추가합니다</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-12 text-center text-gray-500">
|
||||||
|
준비 중
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,391 +0,0 @@
|
||||||
import { useState } from 'react'
|
|
||||||
import { api } from '../../api/client'
|
|
||||||
|
|
||||||
const DIFF_KEYS = { '이지': 'easy', '노말': 'normal', '하드': 'hard', '카오스': 'chaos', '익스트림': 'extreme' }
|
|
||||||
const DIFF_COLORS = {
|
|
||||||
'이지': 'text-green-400 border-green-400/30 bg-green-400/10',
|
|
||||||
'노말': 'text-gray-300 border-gray-500/30 bg-gray-500/10',
|
|
||||||
'하드': 'text-rose-400 border-rose-400/30 bg-rose-400/10',
|
|
||||||
'카오스': 'text-amber-400 border-amber-400/30 bg-amber-400/10',
|
|
||||||
'익스트림': 'text-red-500 border-red-500/30 bg-red-500/10',
|
|
||||||
}
|
|
||||||
|
|
||||||
const DUMMY_BOSSES = [
|
|
||||||
{
|
|
||||||
id: 1, name: '자쿰', imgId: 1,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '이지', crystal: 6_612_500, maxParty: 1 },
|
|
||||||
{ name: '노말', crystal: 16_200_000, maxParty: 1 },
|
|
||||||
{ name: '카오스', crystal: 81_000_000, maxParty: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2, name: '힐라', imgId: 3,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '노말', crystal: 6_612_500, maxParty: 1 },
|
|
||||||
{ name: '하드', crystal: 56_250_000, maxParty: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3, name: '매그너스', imgId: 10,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '이지', crystal: 7_200_000, maxParty: 1 },
|
|
||||||
{ name: '노말', crystal: 19_012_500, maxParty: 1 },
|
|
||||||
{ name: '하드', crystal: 95_062_500, maxParty: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 4, name: '파풀라투스', imgId: 22,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '이지', crystal: 4_012_500, maxParty: 1 },
|
|
||||||
{ name: '노말', crystal: 13_012_500, maxParty: 1 },
|
|
||||||
{ name: '카오스', crystal: 79_012_500, maxParty: 1 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 5, name: '듄켈', imgId: 27,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '노말', crystal: 92_450_000, maxParty: 1 },
|
|
||||||
{ name: '하드', crystal: 231_125_000, maxParty: 6 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 6, name: '림보', imgId: 33,
|
|
||||||
difficulties: [
|
|
||||||
{ name: '노말', crystal: 140_000_000, maxParty: 1 },
|
|
||||||
{ name: '하드', crystal: 350_000_000, maxParty: 6 },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
function formatMeso(n) {
|
|
||||||
if (n >= 100_000_000) {
|
|
||||||
const uk = Math.floor(n / 100_000_000)
|
|
||||||
const man = Math.floor((n % 100_000_000) / 10_000)
|
|
||||||
return man > 0 ? `${uk}억 ${man.toLocaleString()}만` : `${uk}억`
|
|
||||||
}
|
|
||||||
if (n >= 10_000) return `${Math.floor(n / 10_000).toLocaleString()}만`
|
|
||||||
return n.toLocaleString()
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 좌측: 캐릭터 패널 ── */
|
|
||||||
function CharacterPanel({ characters, selectedChar, onSelect, onAdd, onRemove }) {
|
|
||||||
const [name, setName] = useState('')
|
|
||||||
const [loading, setLoading] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const handleSearch = async (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!name.trim()) return
|
|
||||||
setLoading(true)
|
|
||||||
setError('')
|
|
||||||
try {
|
|
||||||
const data = await api(`/api/characters/search?name=${encodeURIComponent(name.trim())}`)
|
|
||||||
onAdd(data)
|
|
||||||
setName('')
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">1. 캐릭터 등록</h2>
|
|
||||||
<form onSubmit={handleSearch} className="flex gap-2">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
placeholder="닉네임 입력"
|
|
||||||
className="flex-1 min-w-0 rounded border border-gray-700 bg-gray-900 px-3 py-1.5 text-sm outline-none focus:border-emerald-500 transition"
|
|
||||||
/>
|
|
||||||
<button type="submit" disabled={loading} className="rounded bg-emerald-600 px-3 py-1.5 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 transition shrink-0">
|
|
||||||
{loading ? '...' : '등록'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
{error && <p className="text-xs text-red-400">{error}</p>}
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
|
||||||
{characters.map((char) => (
|
|
||||||
<div
|
|
||||||
key={char.character_name}
|
|
||||||
onClick={() => onSelect(char.character_name)}
|
|
||||||
className={`flex items-center gap-2 rounded-lg px-2 py-2 cursor-pointer transition group ${
|
|
||||||
selectedChar === char.character_name
|
|
||||||
? 'bg-emerald-500/10 border border-emerald-500/50'
|
|
||||||
: 'hover:bg-gray-800/50 border border-transparent'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{char.character_image && (
|
|
||||||
<img src={char.character_image} alt="" className="w-10 h-10 rounded bg-gray-800" />
|
|
||||||
)}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-medium truncate">{char.character_name}</div>
|
|
||||||
<div className="text-xs text-gray-500">Lv.{char.character_level} {char.job_name}</div>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
onClick={(e) => { e.stopPropagation(); onRemove(char.character_name) }}
|
|
||||||
className="text-gray-700 hover:text-red-400 opacity-0 group-hover:opacity-100 transition cursor-pointer text-lg"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 중앙: 보스 선택 패널 ── */
|
|
||||||
function BossPanel({ selectedChar, selections, onChange }) {
|
|
||||||
if (!selectedChar) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
|
|
||||||
캐릭터를 선택해주세요
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">2. 보스 선택</h2>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-800 overflow-hidden">
|
|
||||||
{/* 헤더 */}
|
|
||||||
<div className="grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 bg-gray-900/80 text-xs text-gray-500 border-b border-gray-800">
|
|
||||||
<div>보스</div>
|
|
||||||
<div>난이도</div>
|
|
||||||
<div>파티원 수</div>
|
|
||||||
<div className="text-right">수익</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 보스 행 */}
|
|
||||||
<div className="divide-y divide-gray-800/50">
|
|
||||||
{DUMMY_BOSSES.map((boss) => {
|
|
||||||
// 현재 캐릭터에서 이 보스의 선택된 난이도 찾기
|
|
||||||
const selectedDiffIdx = boss.difficulties.findIndex((_, i) => {
|
|
||||||
const key = `${boss.id}-${i}`
|
|
||||||
return selections[key]?.enabled
|
|
||||||
})
|
|
||||||
const sel = selectedDiffIdx >= 0 ? selections[`${boss.id}-${selectedDiffIdx}`] : null
|
|
||||||
const diff = selectedDiffIdx >= 0 ? boss.difficulties[selectedDiffIdx] : null
|
|
||||||
const isSelected = !!sel?.enabled
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={boss.id} className={`grid grid-cols-[2fr_1fr_1fr_1fr] gap-2 px-3 py-2 items-center transition ${isSelected ? '' : 'opacity-40'}`}>
|
|
||||||
{/* 보스 이름 + 아이콘 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<img src={`/boss-images/icon/${boss.imgId}.png`} alt={boss.name} className="w-8 h-8 rounded object-cover shrink-0" />
|
|
||||||
<span className="text-sm font-medium truncate">{boss.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 난이도 선택 */}
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{boss.difficulties.map((d, i) => {
|
|
||||||
const key = `${boss.id}-${i}`
|
|
||||||
const active = selections[key]?.enabled
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={i}
|
|
||||||
onClick={() => {
|
|
||||||
// 라디오 방식: 같은 보스에서 하나만 선택
|
|
||||||
const newSelections = { ...selections }
|
|
||||||
boss.difficulties.forEach((_, j) => {
|
|
||||||
const k = `${boss.id}-${j}`
|
|
||||||
if (j === i) {
|
|
||||||
newSelections[k] = { enabled: !active, party: active ? d.maxParty : (selections[k]?.party || d.maxParty) }
|
|
||||||
} else {
|
|
||||||
newSelections[k] = { ...newSelections[k], enabled: false }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
onChange(newSelections)
|
|
||||||
}}
|
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border transition ${
|
|
||||||
active ? DIFF_COLORS[d.name] : 'text-gray-600 border-gray-700/50 hover:border-gray-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{d.name}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 파티원 수 */}
|
|
||||||
<div>
|
|
||||||
{isSelected && (
|
|
||||||
<select
|
|
||||||
value={sel.party}
|
|
||||||
onChange={(e) => {
|
|
||||||
const key = `${boss.id}-${selectedDiffIdx}`
|
|
||||||
onChange({ ...selections, [key]: { ...sel, party: Number(e.target.value) } })
|
|
||||||
}}
|
|
||||||
className="bg-gray-800 border border-gray-700 rounded px-1.5 py-0.5 text-xs text-gray-300 outline-none"
|
|
||||||
>
|
|
||||||
{Array.from({ length: diff.maxParty }, (_, i) => i + 1).map((n) => (
|
|
||||||
<option key={n} value={n}>{n}인</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 수익 */}
|
|
||||||
<div className={`text-right text-sm font-medium ${isSelected ? 'text-green-400' : ''}`}>
|
|
||||||
{isSelected ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 우측: 결과 패널 ── */
|
|
||||||
function ResultPanel({ characters, allSelections }) {
|
|
||||||
let totalCrystals = 0
|
|
||||||
let totalRevenue = 0
|
|
||||||
|
|
||||||
const charResults = characters.map((char) => {
|
|
||||||
const charSel = allSelections[char.character_name] || {}
|
|
||||||
let crystals = 0
|
|
||||||
let revenue = 0
|
|
||||||
|
|
||||||
Object.entries(charSel).forEach(([key, sel]) => {
|
|
||||||
if (!sel.enabled) return
|
|
||||||
const [bossId, diffIdx] = key.split('-').map(Number)
|
|
||||||
const boss = DUMMY_BOSSES.find((b) => b.id === bossId)
|
|
||||||
if (!boss) return
|
|
||||||
crystals++
|
|
||||||
revenue += Math.floor(boss.difficulties[diffIdx].crystal / sel.party)
|
|
||||||
})
|
|
||||||
|
|
||||||
totalCrystals += crystals
|
|
||||||
totalRevenue += revenue
|
|
||||||
|
|
||||||
return { name: char.character_name, crystals, revenue }
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wider">3. 결과</h2>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-4 space-y-4">
|
|
||||||
{/* 합산 */}
|
|
||||||
<div className="flex items-baseline justify-between">
|
|
||||||
<div>
|
|
||||||
<span className="text-sm text-gray-400">보유 결정석</span>
|
|
||||||
<div className="text-2xl font-bold">{totalCrystals}<span className="text-gray-500 text-base">/90</span></div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-sm text-gray-400">총 수익</span>
|
|
||||||
<div className="text-2xl font-bold text-green-400">{formatMeso(totalRevenue)}</div>
|
|
||||||
<div className="text-xs text-gray-500">메소</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 결정석 게이지 */}
|
|
||||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
|
||||||
<div
|
|
||||||
className="bg-emerald-500 h-2 rounded-full transition-all"
|
|
||||||
style={{ width: `${Math.min((totalCrystals / 90) * 100, 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 캐릭터별 소계 */}
|
|
||||||
{charResults.length > 0 && (
|
|
||||||
<div className="space-y-1 pt-2 border-t border-gray-800">
|
|
||||||
<div className="text-xs text-gray-500 mb-2">캐릭터별</div>
|
|
||||||
{charResults.map((r) => (
|
|
||||||
<div key={r.name} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-gray-400">{r.name}</span>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="text-gray-500 text-xs">{r.crystals}/12</span>
|
|
||||||
<span className={r.revenue > 0 ? 'text-green-400' : 'text-gray-600'}>{r.revenue > 0 ? formatMeso(r.revenue) : '-'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── 메인 ── */
|
|
||||||
export default function BossPage() {
|
|
||||||
const [characters, setCharacters] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('maple-characters')
|
|
||||||
return saved ? JSON.parse(saved) : []
|
|
||||||
})
|
|
||||||
const [selectedChar, setSelectedChar] = useState(null)
|
|
||||||
const [allSelections, setAllSelections] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('maple-boss-selections')
|
|
||||||
return saved ? JSON.parse(saved) : {}
|
|
||||||
})
|
|
||||||
|
|
||||||
const saveCharacters = (chars) => {
|
|
||||||
setCharacters(chars)
|
|
||||||
localStorage.setItem('maple-characters', JSON.stringify(chars))
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveSelections = (sels) => {
|
|
||||||
setAllSelections(sels)
|
|
||||||
localStorage.setItem('maple-boss-selections', JSON.stringify(sels))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAddCharacter = (charData) => {
|
|
||||||
if (characters.find((c) => c.character_name === charData.character_name)) return
|
|
||||||
saveCharacters([...characters, charData])
|
|
||||||
setSelectedChar(charData.character_name)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleRemoveCharacter = (name) => {
|
|
||||||
saveCharacters(characters.filter((c) => c.character_name !== name))
|
|
||||||
if (selectedChar === name) setSelectedChar(null)
|
|
||||||
const newSelections = { ...allSelections }
|
|
||||||
delete newSelections[name]
|
|
||||||
saveSelections(newSelections)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleBossChange = (charSelections) => {
|
|
||||||
if (!selectedChar) return
|
|
||||||
saveSelections({ ...allSelections, [selectedChar]: charSelections })
|
|
||||||
}
|
|
||||||
|
|
||||||
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 lg:space-y-0 lg:grid lg:grid-cols-[240px_1fr_280px] lg:gap-6">
|
|
||||||
{/* 좌측 */}
|
|
||||||
<div className="lg:border-r lg:border-gray-800 lg:pr-6">
|
|
||||||
<CharacterPanel
|
|
||||||
characters={characters}
|
|
||||||
selectedChar={selectedChar}
|
|
||||||
onSelect={setSelectedChar}
|
|
||||||
onAdd={handleAddCharacter}
|
|
||||||
onRemove={handleRemoveCharacter}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 중앙 */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<BossPanel
|
|
||||||
selectedChar={selectedChar}
|
|
||||||
selections={currentSelections}
|
|
||||||
onChange={handleBossChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 우측 */}
|
|
||||||
<div className="lg:border-l lg:border-gray-800 lg:pl-6">
|
|
||||||
<ResultPanel
|
|
||||||
characters={characters}
|
|
||||||
allSelections={allSelections}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1 +1,16 @@
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--font-sans: "Maplestory", "Noto Sans KR", system-ui, -apple-system, sans-serif;
|
||||||
|
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-feature-settings: "ss01", "ss02";
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { useSearchParams, Navigate } from 'react-router-dom'
|
|
||||||
import { api } from '../api/client'
|
|
||||||
|
|
||||||
export default function Admin() {
|
|
||||||
const [searchParams] = useSearchParams()
|
|
||||||
const [verified, setVerified] = useState(null) // null=로딩, true=인증됨, false=실패
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const keyFromUrl = searchParams.get('key')
|
|
||||||
const keyFromStorage = localStorage.getItem('maple-admin-key')
|
|
||||||
const key = keyFromUrl || keyFromStorage
|
|
||||||
|
|
||||||
if (!key) {
|
|
||||||
setVerified(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api('/api/admin/verify', { method: 'POST', body: { key } })
|
|
||||||
.then(() => {
|
|
||||||
localStorage.setItem('maple-admin-key', key)
|
|
||||||
setVerified(true)
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
localStorage.removeItem('maple-admin-key')
|
|
||||||
setVerified(false)
|
|
||||||
})
|
|
||||||
}, [searchParams])
|
|
||||||
|
|
||||||
if (verified === null) {
|
|
||||||
return <div className="text-center text-gray-400 pt-16">인증 중...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!verified) {
|
|
||||||
return <Navigate to="/" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-2xl font-bold">관리자</h1>
|
|
||||||
<button
|
|
||||||
onClick={() => { localStorage.removeItem('maple-admin-key'); setVerified(false) }}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-300 transition"
|
|
||||||
>
|
|
||||||
로그아웃
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
|
|
||||||
관리자 페이지 준비 중
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,22 +1,74 @@
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { api } from '../api/client'
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
|
const [menus, setMenus] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api('/api/menus')
|
||||||
|
.then(setMenus)
|
||||||
|
.catch(() => setMenus([]))
|
||||||
|
.finally(() => setLoading(false))
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center gap-8 pt-16">
|
<div className="space-y-12">
|
||||||
<h1 className="text-4xl font-bold">메이플스토리 도우미</h1>
|
{/* Hero */}
|
||||||
<p className="text-gray-400">메이플스토리 유틸리티 모음</p>
|
<section className="text-center pt-12 pb-4">
|
||||||
<div className="grid gap-4 w-full max-w-md">
|
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 text-xs text-emerald-300 mb-6">
|
||||||
<Link
|
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||||
to="/boss"
|
MapleStory Utility
|
||||||
className="flex items-center gap-4 rounded-lg border border-gray-800 p-6 hover:border-gray-600 transition"
|
</div>
|
||||||
>
|
<h1 className="text-4xl sm:text-5xl font-bold tracking-tight bg-gradient-to-br from-white via-white to-gray-500 bg-clip-text text-transparent">
|
||||||
<span className="text-3xl">💎</span>
|
메이플스토리 유틸리티
|
||||||
<div>
|
</h1>
|
||||||
<h2 className="text-lg font-semibold">주간 보스 수익 계산기</h2>
|
<p className="text-gray-400 mt-4 text-base">
|
||||||
<p className="text-sm text-gray-400">캐릭터별 보스 결정석 수익을 계산합니다</p>
|
메이플스토리 플레이를 위한 유용한 도구 모음
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* 메뉴 그리드 */}
|
||||||
|
<section>
|
||||||
|
{loading ? (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 3 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
) : menus.length === 0 ? (
|
||||||
</div>
|
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
|
||||||
|
<div className="text-5xl mb-4 opacity-50">🍁</div>
|
||||||
|
<p className="text-gray-400">아직 등록된 기능이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{menus.map((menu) => (
|
||||||
|
<Link
|
||||||
|
key={menu.id}
|
||||||
|
to={menu.url}
|
||||||
|
className="group relative overflow-hidden rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-6 hover:border-emerald-500/30 transition-all duration-300"
|
||||||
|
>
|
||||||
|
<div className="absolute -top-16 -right-16 w-40 h-40 rounded-full bg-emerald-500/0 group-hover:bg-emerald-500/10 blur-3xl transition-all duration-500" />
|
||||||
|
<div className="relative space-y-3">
|
||||||
|
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center text-2xl group-hover:scale-110 group-hover:border-emerald-500/30 transition-all duration-300">
|
||||||
|
{menu.image_url ? (
|
||||||
|
<img src={menu.image_url} alt={menu.title} className="w-7 h-7 object-contain" />
|
||||||
|
) : (
|
||||||
|
menu.icon || '📋'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold group-hover:text-emerald-300 transition">{menu.title}</h2>
|
||||||
|
<p className="text-sm text-gray-400 mt-1 leading-relaxed">{menu.description}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue