diff --git a/frontend/index.html b/frontend/index.html index e663c8b..4223fe8 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,9 +2,13 @@ - + - 메이플스토리 도우미 + + + + + 메이플스토리 유틸리티
diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..746727f Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg deleted file mode 100644 index 6893eb1..0000000 --- a/frontend/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0a317e6..69d29b1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,16 +1,21 @@ import { Routes, Route } from 'react-router-dom' import Layout from './components/Layout' import Home from './pages/Home' -import BossPage from './features/boss/BossPage' -import Admin from './pages/Admin' +import AdminLayout from './features/admin/AdminLayout' +import AdminHome from './features/admin/AdminHome' +import AdminImages from './features/admin/AdminImages' +import AdminMenuForm from './features/admin/AdminMenuForm' export default function App() { return ( }> } /> - } /> - } /> + }> + } /> + } /> + } /> + ) diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index f9b260d..363077a 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -2,16 +2,18 @@ import { Outlet, Link } from 'react-router-dom' export default function Layout() { return ( -
-
-
- 메이플스토리 도우미 - +
+
+
+ + + + 메이플스토리 유틸리티 + +
-
+
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.title} + ) : ( + 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 && ( + + ← + + )} +
+
Admin
+

관리자

+
+
+ +
+ + +
+ ) +} 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. 캐릭터 등록

-
- 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" - /> - -
- {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.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.title} + ) : ( + menu.icon || '📋' + )} +
+
+

{menu.title}

+

{menu.description}

+
+
+ + ))} +
+ )} +
) }