- API 키 로그인 다이얼로그 + 헤더 로그인 버튼 - /api/character/list 프록시 엔드포인트 (월드 아이콘 매핑 포함) - 캐릭터 입력 포커스 시 드롭다운 (월드 아이콘, 레벨 정렬, 기존 캐릭 제외, 페이드 애니메이션) - 관리자 인증을 API 키로 통합 (URL ?key= 파라미터 폐기) - 헤더에 관리자 링크 버튼 / 홈 링크 버튼 (경로별 배타적 표시) - 관리자 페이지에서 타이틀 우측에 "관리자" 텍스트 - 이미지 관리 페이지 테마 토큰화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
109 lines
4.1 KiB
JavaScript
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>
|
|
)
|
|
}
|