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 ? '캐릭터가 없습니다' : '일치하는 캐릭터가 없습니다'} +
+ ) : ( + + )} +
+ )} +
+ ) +} 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 }) {
{/* 버튼 */} -
- @@ -169,22 +226,32 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied }) return (
selectMode && onToggle(image.id)} - className={`group relative rounded-xl border overflow-hidden transition ${ - selected - ? 'border-emerald-500/60 bg-emerald-500/5 ring-2 ring-emerald-500/30' - : 'border-white/5 bg-gray-900/40 hover:border-white/15' - } ${selectMode ? 'cursor-pointer' : ''}`} + className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`} + style={{ + borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)', + background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)', + boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)', + }} > - {/* 체크박스 (선택모드) */} {selectMode && ( -
- {selected && } +
+ {selected && }
)} -
+
{image.name}
-
+
{image.name}
@@ -223,50 +298,61 @@ function Pagination({ page, totalPages, onChange }) { if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1) for (let i = start; i <= end; i++) pages.push(i) - const btn = "min-w-9 h-9 px-3 rounded-lg text-sm transition flex items-center justify-center" + const baseBtn = "min-w-9 h-9 px-3 rounded-lg text-sm flex items-center justify-center border hover:bg-[var(--btn-bg-hover)]" + const btnStyle = { + background: 'var(--btn-bg)', + borderColor: 'var(--btn-border)', + color: 'var(--text-emphasis)', + } return (
{start > 1 && ( <> - - {start > 2 && } + + {start > 2 && } )} - {pages.map((p) => ( - - ))} + {pages.map((p) => { + const active = p === page + return ( + + ) + })} {end < totalPages && ( <> - {end < totalPages - 1 && } - + {end < totalPages - 1 && } + )} @@ -285,10 +371,9 @@ export default function AdminImages() { const [uploadOpen, setUploadOpen] = useState(false) const [selectMode, setSelectMode] = useState(false) const [selectedIds, setSelectedIds] = useState(new Set()) - const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names} + const [confirmDelete, setConfirmDelete] = useState(null) const [copiedId, setCopiedId] = useState(null) - // 검색어 디바운싱 useEffect(() => { const t = setTimeout(() => { setDebouncedSearch(search) @@ -297,7 +382,6 @@ export default function AdminImages() { return () => clearTimeout(t) }, [search]) - // 이미지 목록 (페이징 + 검색) const { data: imagesData, isLoading } = useQuery({ queryKey: ['admin', 'images', { page, search: debouncedSearch }], queryFn: async () => { @@ -314,7 +398,6 @@ export default function AdminImages() { const images = imagesData?.items || [] const totalPages = imagesData?.total_pages || 1 - // 전체 이름 (중복 체크용) const { data: allNamesArray = [] } = useQuery({ queryKey: ['admin', 'images', 'names'], queryFn: () => api('/api/admin/images/names'), @@ -325,7 +408,6 @@ export default function AdminImages() { queryClient.invalidateQueries({ queryKey: ['admin', 'images'] }) } - // 업로드 const uploadMutation = useMutation({ mutationFn: async (items) => { const formData = new FormData() @@ -333,7 +415,7 @@ export default function AdminImages() { formData.append('files', it.file) formData.append('names', it.name.trim()) }) - const adminKey = localStorage.getItem('maple-admin-key') + const adminKey = useAuthStore.getState().apiKey const res = await fetch('/api/admin/images', { method: 'POST', headers: { 'x-admin-key': adminKey }, @@ -382,7 +464,6 @@ export default function AdminImages() { }) } - // 삭제 const deleteMutation = useMutation({ mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }), onSuccess: () => { @@ -404,29 +485,44 @@ export default function AdminImages() {
-

이미지 관리

-

공용 이미지를 업로드하고 관리합니다

+

이미지 관리

+

공용 이미지를 업로드하고 관리합니다

{selectMode ? ( <> - {selectedIds.size}개 선택 + {selectedIds.size}개 선택 @@ -436,14 +532,23 @@ export default function AdminImages() { {images.length > 0 && ( )}
)} @@ -471,19 +581,30 @@ export default function AdminImages() { {isLoading ? (
{Array.from({ length: 8 }).map((_, i) => ( -
+
))}
) : images.length === 0 ? ( -
+
🖼️
-

+

{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}

{!debouncedSearch && ( diff --git a/frontend/src/features/admin/AdminLayout.jsx b/frontend/src/features/admin/AdminLayout.jsx index 1dc186a..613b0cf 100644 --- a/frontend/src/features/admin/AdminLayout.jsx +++ b/frontend/src/features/admin/AdminLayout.jsx @@ -1,23 +1,20 @@ -import { useSearchParams, Outlet, Navigate } from 'react-router-dom' +import { Outlet, Navigate } from 'react-router-dom' import { useQuery, useQueryClient } from '@tanstack/react-query' import { api } from '../../api/client' +import { useAuthStore } from '../../stores/auth' export default function AdminLayout() { const queryClient = useQueryClient() - const [searchParams] = useSearchParams() - - const keyFromUrl = searchParams.get('key') - const key = keyFromUrl || localStorage.getItem('maple-admin-key') + const apiKey = useAuthStore((s) => s.apiKey) + const clearApiKey = useAuthStore((s) => s.clearApiKey) const { data, isLoading } = useQuery({ - queryKey: ['admin', 'verify', key], + queryKey: ['admin', 'verify', apiKey], queryFn: async () => { - if (!key) throw new Error('no key') - await api('/api/admin/verify', { method: 'POST', body: { key } }) - localStorage.setItem('maple-admin-key', key) + await api('/api/admin/verify', { method: 'POST', body: { key: apiKey } }) return true }, - enabled: !!key, + enabled: !!apiKey, retry: false, staleTime: Infinity, }) @@ -25,23 +22,20 @@ export default function AdminLayout() { const verified = data === true const handleLogout = () => { - localStorage.removeItem('maple-admin-key') + clearApiKey() queryClient.removeQueries({ queryKey: ['admin'] }) window.location.href = '/' } - if (key && isLoading) { + if (apiKey && isLoading) { return (
-
+
) } - if (!verified) { - if (key) localStorage.removeItem('maple-admin-key') - return - } + if (!verified) return return } diff --git a/frontend/src/features/boss-crystal/user/CharacterPanel.jsx b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx index 621bf0e..cf47d87 100644 --- a/frontend/src/features/boss-crystal/user/CharacterPanel.jsx +++ b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx @@ -5,6 +5,7 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-react' import { api } from '../../../api/client' import ConfirmDialog from '../../../components/ConfirmDialog' import Tooltip from '../../../components/Tooltip' +import CharacterSuggestDropdown from '../../../components/CharacterSuggestDropdown' import { useFitText } from '../../../hooks/useFitText' import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants' @@ -198,6 +199,7 @@ export default function CharacterPanel({ const [name, setName] = useState('') const [error, setError] = useState('') const [confirmRemove, setConfirmRemove] = useState(null) + const [dropdownOpen, setDropdownOpen] = useState(false) const searchMutation = useMutation({ mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`), @@ -331,6 +333,8 @@ export default function CharacterPanel({ type="text" value={name} onChange={(e) => { setName(e.target.value); if (error) setError('') }} + onFocus={() => setDropdownOpen(true)} + onBlur={() => setTimeout(() => setDropdownOpen(false), 150)} placeholder="캐릭터 닉네임 검색" className="w-full rounded-lg border-2 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]" style={{ @@ -339,6 +343,17 @@ export default function CharacterPanel({ color: 'var(--text-strong)', }} /> + c.character_name)} + onSelect={(n) => { + setName(n) + setDropdownOpen(false) + setError('') + searchMutation.mutate(n) + }} + />