diff --git a/backend/routes/character.js b/backend/routes/character.js
index 7ddef1e..d7c3862 100644
--- a/backend/routes/character.js
+++ b/backend/routes/character.js
@@ -1,5 +1,8 @@
import { Router } from 'express';
import axios from 'axios';
+import { Op } from 'sequelize';
+import { Image } from '../models/index.js';
+import { getPublicUrl } from '../lib/s3.js';
const router = Router();
const NEXON_API_BASE = 'https://open.api.nexon.com';
@@ -78,4 +81,68 @@ router.get('/symbols', async (req, res) => {
}
});
+// API 키로 캐릭터 목록 조회 (사용자 제공 키 사용)
+router.get('/list', async (req, res) => {
+ const key = req.header('x-user-api-key');
+ if (!key) return res.status(400).json({ error: 'API 키가 필요합니다' });
+
+ try {
+ const { data } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/list`, {
+ headers: { 'x-nxopen-api-key': key },
+ });
+
+ // 계정별 캐릭터를 하나로 합치고 월드 필터링 (스페셜/리부트 제외)
+ const characters = [];
+ for (const acc of data.account_list || []) {
+ for (const c of acc.character_list || []) {
+ const world = c.world_name || '';
+ if (world.includes('스페셜') || world.includes('리부트')) continue;
+ characters.push({
+ ocid: c.ocid,
+ character_name: c.character_name,
+ world_name: world,
+ job_name: c.character_class_name || c.character_class,
+ character_level: c.character_level,
+ });
+ }
+ }
+
+ characters.sort((a, b) => (b.character_level || 0) - (a.character_level || 0));
+
+ // 월드 아이콘 매핑 ("월드 : 월드명", "월드:월드명" 등 공백 유연하게 매칭)
+ const worldNames = [...new Set(characters.map((c) => c.world_name).filter(Boolean))];
+ if (worldNames.length) {
+ const images = await Image.findAll({
+ where: {
+ [Op.or]: [
+ { name: { [Op.like]: '월드%' } },
+ ...worldNames.map((w) => ({ name: w })),
+ ],
+ },
+ });
+ const worldIconMap = {};
+ for (const img of images) {
+ const m = img.name.match(/^월드\s*:\s*(.+)$/);
+ const key = m ? m[1].trim() : img.name.trim();
+ worldIconMap[key] = getPublicUrl(img.path);
+ }
+ for (const c of characters) {
+ c.world_icon = worldIconMap[c.world_name] || null;
+ }
+ }
+
+ res.json({ characters });
+ } catch (err) {
+ const code = err.response?.data?.error?.name;
+ if (['OPENAPI00001', 'OPENAPI00007', 'OPENAPI00010', 'OPENAPI00011'].includes(code)) {
+ return res.status(503).json({ error: 'API 점검중입니다', code, maintenance: true });
+ }
+ if (err.response?.status === 401 || err.response?.status === 403 || code === 'OPENAPI00004') {
+ return res.status(401).json({ error: '유효하지 않은 API 키입니다' });
+ }
+ console.error('캐릭터 목록 조회 오류:', err.response?.data || err.message);
+ res.status(500).json({ error: '캐릭터 목록 조회 실패' });
+ }
+});
+
export default router;
diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js
index 0171bac..718ad71 100644
--- a/frontend/src/api/client.js
+++ b/frontend/src/api/client.js
@@ -1,9 +1,11 @@
+import { useAuthStore } from '../stores/auth'
+
export async function api(url, options = {}) {
const headers = { 'Content-Type': 'application/json', ...options.headers }
- // 관리자 API에는 인증 헤더 자동 추가
+ // 관리자 API에는 로그인 다이얼로그에서 저장한 키를 자동으로 헤더에 포함
if (url.startsWith('/api/admin')) {
- const adminKey = localStorage.getItem('maple-admin-key')
+ const adminKey = useAuthStore.getState().apiKey
if (adminKey) headers['x-admin-key'] = adminKey
}
diff --git a/frontend/src/components/CharacterSuggestDropdown.jsx b/frontend/src/components/CharacterSuggestDropdown.jsx
new file mode 100644
index 0000000..7feac5a
--- /dev/null
+++ b/frontend/src/components/CharacterSuggestDropdown.jsx
@@ -0,0 +1,109 @@
+import { useMemo } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { motion, AnimatePresence } from 'framer-motion'
+import { api } from '../api/client'
+import { useAuthStore } from '../stores/auth'
+
+/**
+ * 캐릭터 입력 input 아래 뜨는 드롭다운
+ * - 로그인된 API 키로 /api/character/list 조회
+ * - input value로 필터링
+ * - 항목 클릭 시 onSelect(characterName)
+ * - excludeNames: 이미 추가된 캐릭터는 목록에서 제외
+ */
+export default function CharacterSuggestDropdown({ open, filter = '', excludeNames = [], onSelect }) {
+ const apiKey = useAuthStore((s) => s.apiKey)
+
+ const { data = [], isLoading, error } = useQuery({
+ queryKey: ['user-character-list', apiKey],
+ queryFn: async () => {
+ const r = await api('/api/character/list', { headers: { 'x-user-api-key': apiKey } })
+ return r.characters || []
+ },
+ enabled: open && !!apiKey,
+ staleTime: 10 * 60 * 1000,
+ retry: false,
+ })
+
+ const filtered = useMemo(() => {
+ const exclude = new Set(excludeNames)
+ const q = filter.trim().toLowerCase()
+ return data
+ .filter((c) => !exclude.has(c.character_name))
+ .filter((c) => !q || c.character_name.toLowerCase().includes(q))
+ .slice()
+ .sort((a, b) => (b.character_level || 0) - (a.character_level || 0))
+ .slice(0, 50)
+ }, [data, filter, excludeNames])
+
+ return (
+
+ {open && apiKey && (
+
+ {isLoading ? (
+ 불러오는 중...
+ ) : error ? (
+
+ {error.message || '조회 실패'}
+
+ ) : filtered.length === 0 ? (
+
+ {data.length === 0 ? '캐릭터가 없습니다' : '일치하는 캐릭터가 없습니다'}
+
+ ) : (
+
+ {filtered.map((c) => (
+ -
+
+
+ ))}
+
+ )}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index 3bc8d55..34d668a 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -3,7 +3,9 @@ import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../api/client'
import Footer from './Footer'
+import LoginDialog from './LoginDialog'
import { useThemeStore } from '../stores/theme'
+import { useAuthStore } from '../stores/auth'
const SITE_NAME = '메이플스토리 유틸리티'
@@ -38,6 +40,23 @@ function CurrentMenuTitle() {
}
}, [isAdmin, menu])
+ if (isAdmin) {
+ return (
+
+ /
+
+ 관리자
+
+
+ )
+ }
+
if (!menu) return null
return (
@@ -102,8 +121,93 @@ function ThemeToggle() {
)
}
+function LoginButton({ onClick }) {
+ const apiKey = useAuthStore((s) => s.apiKey)
+ const loggedIn = !!apiKey
+
+ return (
+
+ )
+}
+
+function AdminLinkButton() {
+ const apiKey = useAuthStore((s) => s.apiKey)
+ const isAdminRoute = !!useMatch('/admin/*')
+ const { data } = useQuery({
+ queryKey: ['admin', 'verify', apiKey],
+ queryFn: async () => {
+ await api('/api/admin/verify', { method: 'POST', body: { key: apiKey } })
+ return true
+ },
+ enabled: !!apiKey,
+ retry: false,
+ staleTime: Infinity,
+ })
+
+ if (data !== true || isAdminRoute) return null
+
+ return (
+
+
+ 관리자
+
+ )
+}
+
+function HomeLinkButton() {
+ const isAdminRoute = !!useMatch('/admin/*')
+ if (!isAdminRoute) return null
+
+ return (
+
+
+ 홈으로
+
+ )
+}
+
export default function Layout() {
const [fullscreen, setFullscreen] = useState(false)
+ const [loginOpen, setLoginOpen] = useState(false)
const isAdmin = !!useMatch('/admin/*')
const homeTo = isAdmin ? '/admin' : '/'
const theme = useThemeStore((s) => s.theme)
@@ -138,9 +242,15 @@ export default function Layout() {
-
+
+
setLoginOpen(true)} />
+
+
+
+
+ setLoginOpen(false)} />
diff --git a/frontend/src/components/LoginDialog.jsx b/frontend/src/components/LoginDialog.jsx
new file mode 100644
index 0000000..2e168d8
--- /dev/null
+++ b/frontend/src/components/LoginDialog.jsx
@@ -0,0 +1,170 @@
+import { useEffect, useState } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import { useAuthStore } from '../stores/auth'
+import { api } from '../api/client'
+
+export default function LoginDialog({ open, onClose }) {
+ const apiKey = useAuthStore((s) => s.apiKey)
+ const setApiKey = useAuthStore((s) => s.setApiKey)
+ const clearApiKey = useAuthStore((s) => s.clearApiKey)
+
+ const [input, setInput] = useState('')
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+
+ useEffect(() => {
+ if (open) {
+ setInput(apiKey || '')
+ setError('')
+ setBusy(false)
+ }
+ }, [open, apiKey])
+
+ const handleSave = async () => {
+ const key = input.trim()
+ if (!key) {
+ setError('API 키를 입력해주세요')
+ return
+ }
+ setError('')
+ setBusy(true)
+ try {
+ await api('/api/character/list', { headers: { 'x-user-api-key': key } })
+ setApiKey(key)
+ onClose()
+ } catch (err) {
+ setError(err.message || '키 검증 실패')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ const handleLogout = () => {
+ clearApiKey()
+ setInput('')
+ onClose()
+ }
+
+ return (
+
+ {open && (
+
+ e.stopPropagation()}
+ >
+
+
+
+
API 키 로그인
+
+ NEXON Open API 키를 입력하면 계정의 캐릭터 목록을 불러올 수 있습니다
+
+
+
+
+
+
{ setInput(e.target.value); if (error) setError('') }}
+ onKeyDown={(e) => { if (e.key === 'Enter' && !busy) handleSave() }}
+ placeholder="live_xxxxxxxxxxxxxxxxxx..."
+ className="w-full rounded-lg border-2 px-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)] font-mono"
+ style={{
+ background: 'var(--input-bg)',
+ borderColor: 'var(--input-border)',
+ color: 'var(--text-strong)',
+ }}
+ autoFocus
+ />
+ {error && (
+
{error}
+ )}
+
+ 키는 브라우저에만 저장되며 서버로 전송되지 않습니다.
+
+
+
+ {apiKey ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/admin/AdminImages.jsx b/frontend/src/features/admin/AdminImages.jsx
index 79ccadf..6464a1d 100644
--- a/frontend/src/features/admin/AdminImages.jsx
+++ b/frontend/src/features/admin/AdminImages.jsx
@@ -2,16 +2,37 @@ import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../api/client'
import ConfirmDialog from '../../components/ConfirmDialog'
+import { useAuthStore } from '../../stores/auth'
/* ── 공용 모달 ── */
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
if (!open) return null
return (
-
-
e.stopPropagation()}>
-
-
{title}
-
+
+
e.stopPropagation()}
+ >
+
+
{title}
+
{children}
@@ -21,7 +42,7 @@ function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
/* ── 업로드 모달 (다중 지원) ── */
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
- const [items, setItems] = useState([]) // { file, name, preview, id }
+ const [items, setItems] = useState([])
const [dragOver, setDragOver] = useState(false)
useEffect(() => {
@@ -81,13 +102,18 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
setDragOver(false)
addFiles(e.dataTransfer.files)
}}
- className={`relative rounded-xl border-2 border-dashed transition cursor-pointer min-h-[120px] flex flex-col items-center justify-center ${
- dragOver ? 'border-emerald-500 bg-emerald-500/10' : 'border-white/10 hover:border-white/20 bg-white/[0.02]'
- }`}
+ className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
+ style={dragOver ? {
+ borderColor: 'var(--selected-border)',
+ background: 'var(--selected-bg)',
+ } : {
+ borderColor: 'var(--dashed-border)',
+ background: 'var(--skeleton-bg)',
+ }}
>
📥
-
클릭하거나 이미지를 끌어다 놓으세요
-
여러 개 선택 가능
+
클릭하거나 이미지를 끌어다 놓으세요
+
여러 개 선택 가능
-
+
+
{item.preview ? (

) : (
-
+
)}
@@ -126,16 +160,22 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
type="text"
value={item.name}
onChange={(e) => updateName(item.id, e.target.value)}
- className={`w-full rounded border bg-gray-900 px-2 py-1.5 text-sm outline-none transition ${
- errorMsg ? 'border-red-500/40 focus:border-red-500/60' : 'border-white/10 focus:border-emerald-500/50'
- }`}
+ className="w-full rounded border px-2 py-1.5 text-sm outline-none"
+ style={{
+ background: 'var(--input-bg)',
+ borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
+ color: 'var(--text-strong)',
+ }}
/>
- {errorMsg &&
{errorMsg}
}
+ {errorMsg && (
+
{errorMsg}
+ )}
@@ -147,14 +187,31 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
{/* 버튼 */}
-
-