maplestory/frontend/src/components/CharacterSuggestDropdown.jsx
caadiq 85b9a6b6d2 API 키 로그인 + 캐릭터 드롭다운 + 관리자 네비게이션
- API 키 로그인 다이얼로그 + 헤더 로그인 버튼
- /api/character/list 프록시 엔드포인트 (월드 아이콘 매핑 포함)
- 캐릭터 입력 포커스 시 드롭다운 (월드 아이콘, 레벨 정렬, 기존 캐릭 제외, 페이드 애니메이션)
- 관리자 인증을 API 키로 통합 (URL ?key= 파라미터 폐기)
- 헤더에 관리자 링크 버튼 / 홈 링크 버튼 (경로별 배타적 표시)
- 관리자 페이지에서 타이틀 우측에 "관리자" 텍스트
- 이미지 관리 페이지 테마 토큰화

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 10:54:12 +09:00

109 lines
4.1 KiB
JavaScript

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 (
<AnimatePresence>
{open && apiKey && (
<motion.div
key="dropdown"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15, ease: [0.22, 1, 0.36, 1] }}
className="absolute top-full left-0 right-0 mt-1 z-30 rounded-lg border max-h-64 overflow-y-auto"
style={{
background: 'var(--popup-bg)',
borderColor: 'var(--popup-border)',
boxShadow: 'var(--popup-shadow)',
}}
>
{isLoading ? (
<div className="p-4 text-center text-sm" style={{ color: 'var(--text-dim)' }}>불러오는 ...</div>
) : error ? (
<div className="p-4 text-center text-sm" style={{ color: 'var(--danger-text)' }}>
{error.message || '조회 실패'}
</div>
) : filtered.length === 0 ? (
<div className="p-4 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
{data.length === 0 ? '캐릭터가 없습니다' : '일치하는 캐릭터가 없습니다'}
</div>
) : (
<ul>
{filtered.map((c) => (
<li key={c.ocid}>
<button
type="button"
onMouseDown={(e) => { e.preventDefault(); onSelect(c.character_name) }}
className="w-full text-left px-3 py-2.5 flex items-center gap-2 hover:bg-[var(--row-hover-bg)]"
>
{c.world_icon ? (
<img
src={c.world_icon}
alt={c.world_name}
title={c.world_name}
className="w-5 h-5 shrink-0 object-contain"
style={{ imageRendering: 'pixelated' }}
/>
) : (
<span
className="w-5 h-5 shrink-0 rounded-full text-[10px] font-bold flex items-center justify-center"
style={{
background: 'var(--surface-nested)',
color: 'var(--text-dim)',
}}
title={c.world_name}
>
{c.world_name?.[0] || '?'}
</span>
)}
<span className="text-sm font-medium truncate" style={{ color: 'var(--text-strong)' }}>
{c.character_name}
</span>
<span className="text-xs shrink-0" style={{ color: 'var(--text-dim)' }}>
Lv.{c.character_level} · {c.job_name}
</span>
</button>
</li>
))}
</ul>
)}
</motion.div>
)}
</AnimatePresence>
)
}