-
메이플스토리 도우미
-
+
+
+
+
+

+
+ 메이플스토리 유틸리티
+
+
-
+
diff --git a/frontend/src/features/admin/AdminBoss.jsx b/frontend/src/features/admin/AdminBoss.jsx
new file mode 100644
index 0000000..524a3e2
--- /dev/null
+++ b/frontend/src/features/admin/AdminBoss.jsx
@@ -0,0 +1,10 @@
+export default function AdminBoss() {
+ return (
+
+
보스 수익 계산기 관리
+
+ 준비 중
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminHome.jsx b/frontend/src/features/admin/AdminHome.jsx
new file mode 100644
index 0000000..307bed5
--- /dev/null
+++ b/frontend/src/features/admin/AdminHome.jsx
@@ -0,0 +1,116 @@
+import { useState, useEffect } from 'react'
+import { Link } from 'react-router-dom'
+import { api } from '../../api/client'
+
+function MenuCard({ menu }) {
+ return (
+
+
+
+
+
+ {menu.image_url ? (
+

+ ) : (
+ menu.icon || '📋'
+ )}
+
+
+
{menu.title}
+
{menu.description}
+
+
+ →
+
+
+
+ )
+}
+
+function AddCard({ to, icon, label }) {
+ return (
+
+
+ {icon}
+
+
{label}
+
+ )
+}
+
+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 (
+
+ {/* 메뉴 섹션 */}
+
+
+
+
기능 관리
+
메뉴 항목을 추가하거나 관리합니다
+
+
+
+
+ {loading ? (
+ Array.from({ length: 3 }).map((_, i) => (
+
+ ))
+ ) : (
+ <>
+ {menus.map((menu) => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ {/* 자원 관리 섹션 */}
+
+
+
자원 관리
+
공용 이미지 등 사이트 자원을 관리합니다
+
+
+
+
+
+
+
+ 🖼️
+
+
+
이미지 관리
+
공용 이미지 업로드 및 관리
+
+
+ →
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminImages.jsx b/frontend/src/features/admin/AdminImages.jsx
new file mode 100644
index 0000000..e5ed85c
--- /dev/null
+++ b/frontend/src/features/admin/AdminImages.jsx
@@ -0,0 +1,13 @@
+export default function AdminImages() {
+ return (
+
+
+
이미지 관리
+
공용 이미지를 업로드하고 관리합니다
+
+
+ 준비 중
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminLayout.jsx b/frontend/src/features/admin/AdminLayout.jsx
new file mode 100644
index 0000000..45a97f8
--- /dev/null
+++ b/frontend/src/features/admin/AdminLayout.jsx
@@ -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 (
+
+ )
+ }
+
+ if (!verified) {
+ return
+ }
+
+ return (
+
+
+
+ {!isRoot && (
+
+ ←
+
+ )}
+
+
+
+
+
+
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminMenuForm.jsx b/frontend/src/features/admin/AdminMenuForm.jsx
new file mode 100644
index 0000000..f0c194a
--- /dev/null
+++ b/frontend/src/features/admin/AdminMenuForm.jsx
@@ -0,0 +1,13 @@
+export default function AdminMenuForm() {
+ return (
+
+
+
메뉴 항목 추가
+
새 기능 카드를 추가합니다
+
+
+ 준비 중
+
+
+ )
+}
diff --git a/frontend/src/features/boss/BossPage.jsx b/frontend/src/features/boss/BossPage.jsx
deleted file mode 100644
index 69d71d2..0000000
--- a/frontend/src/features/boss/BossPage.jsx
+++ /dev/null
@@ -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 (
-
-
1. 캐릭터 등록
-
- {error &&
{error}
}
-
-
- {characters.map((char) => (
-
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 && (
-

- )}
-
-
{char.character_name}
-
Lv.{char.character_level} {char.job_name}
-
-
{ 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"
- >
- ×
-
-
- ))}
-
-
- )
-}
-
-/* ── 중앙: 보스 선택 패널 ── */
-function BossPanel({ selectedChar, selections, onChange }) {
- if (!selectedChar) {
- return (
-
- 캐릭터를 선택해주세요
-
- )
- }
-
- return (
-
-
2. 보스 선택
-
-
- {/* 헤더 */}
-
-
보스
-
난이도
-
파티원 수
-
수익
-
-
- {/* 보스 행 */}
-
- {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 (
-
- {/* 보스 이름 + 아이콘 */}
-
-

-
{boss.name}
-
-
- {/* 난이도 선택 */}
-
- {boss.difficulties.map((d, i) => {
- const key = `${boss.id}-${i}`
- const active = selections[key]?.enabled
- return (
-
- )
- })}
-
-
- {/* 파티원 수 */}
-
- {isSelected && (
-
- )}
-
-
- {/* 수익 */}
-
- {isSelected ? formatMeso(Math.floor(diff.crystal / sel.party)) : '-'}
-
-
- )
- })}
-
-
-
- )
-}
-
-/* ── 우측: 결과 패널 ── */
-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 (
-
-
3. 결과
-
-
- {/* 합산 */}
-
-
-
보유 결정석
-
{totalCrystals}/90
-
-
-
총 수익
-
{formatMeso(totalRevenue)}
-
메소
-
-
-
- {/* 결정석 게이지 */}
-
-
- {/* 캐릭터별 소계 */}
- {charResults.length > 0 && (
-
-
캐릭터별
- {charResults.map((r) => (
-
-
{r.name}
-
- {r.crystals}/12
- 0 ? 'text-green-400' : 'text-gray-600'}>{r.revenue > 0 ? formatMeso(r.revenue) : '-'}
-
-
- ))}
-
- )}
-
-
- )
-}
-
-/* ── 메인 ── */
-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 (
-
- {/* 좌측 */}
-
-
-
-
- {/* 중앙 */}
-
-
-
-
- {/* 우측 */}
-
-
-
-
- )
-}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f1d8c73..15b0904 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -1 +1,16 @@
@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;
+}
diff --git a/frontend/src/pages/Admin.jsx b/frontend/src/pages/Admin.jsx
deleted file mode 100644
index ccff0d8..0000000
--- a/frontend/src/pages/Admin.jsx
+++ /dev/null
@@ -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
인증 중...
- }
-
- if (!verified) {
- return
- }
-
- return (
-
-
-
관리자
-
-
-
-
- 관리자 페이지 준비 중
-
-
- )
-}
diff --git a/frontend/src/pages/Home.jsx b/frontend/src/pages/Home.jsx
index 6249cb8..064176d 100644
--- a/frontend/src/pages/Home.jsx
+++ b/frontend/src/pages/Home.jsx
@@ -1,22 +1,74 @@
+import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
+import { api } from '../api/client'
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 (
-
-
메이플스토리 도우미
-
메이플스토리 유틸리티 모음
-
-
-
💎
-
-
주간 보스 수익 계산기
-
캐릭터별 보스 결정석 수익을 계산합니다
+
+ {/* Hero */}
+
+
+
+ MapleStory Utility
+
+
+ 메이플스토리 유틸리티
+
+
+ 메이플스토리 플레이를 위한 유용한 도구 모음
+
+
+
+ {/* 메뉴 그리드 */}
+
+ {loading ? (
+
+ {Array.from({ length: 3 }).map((_, i) => (
+
+ ))}
-
-
+ ) : menus.length === 0 ? (
+
+ ) : (
+
+ {menus.map((menu) => (
+
+
+
+
+ {menu.image_url ? (
+

+ ) : (
+ menu.icon || '📋'
+ )}
+
+
+
{menu.title}
+
{menu.description}
+
+
+
+ ))}
+
+ )}
+
)
}