관리자 대시보드 리디자인 및 메뉴/이미지 관리 페이지 추가

- 관리자 페이지 카드형 메뉴 구조로 개편 (DB 연동 준비)
- 메이플스토리 폰트, 단풍잎 favicon 적용
- 헤더 디자인 개선 (백드롭 블러, 단풍잎 로고)
- 홈 페이지를 메뉴 동적 로드 형태로 변경
- 보스 계산기 페이지 제거 (DB 기반으로 재구축 예정)
- 이미지/메뉴 관리 페이지 라우트 추가 (placeholder)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 14:20:32 +09:00
parent 6c51e7d94d
commit 72ff284f20
14 changed files with 331 additions and 475 deletions

View file

@ -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

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

View file

@ -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>
) )

View file

@ -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>

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -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>
)
}

View file

@ -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;
}

View file

@ -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>
)
}

View file

@ -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>
) )
} }