API 키 로그인 + 캐릭터 드롭다운 + 관리자 네비게이션
- API 키 로그인 다이얼로그 + 헤더 로그인 버튼 - /api/character/list 프록시 엔드포인트 (월드 아이콘 매핑 포함) - 캐릭터 입력 포커스 시 드롭다운 (월드 아이콘, 레벨 정렬, 기존 캐릭 제외, 페이드 애니메이션) - 관리자 인증을 API 키로 통합 (URL ?key= 파라미터 폐기) - 헤더에 관리자 링크 버튼 / 홈 링크 버튼 (경로별 배타적 표시) - 관리자 페이지에서 타이틀 우측에 "관리자" 텍스트 - 이미지 관리 페이지 테마 토큰화 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1dfceaf350
commit
85b9a6b6d2
10 changed files with 712 additions and 98 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
109
frontend/src/components/CharacterSuggestDropdown.jsx
Normal file
109
frontend/src/components/CharacterSuggestDropdown.jsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
className="flex items-center gap-3"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-slash)' }}>/</span>
|
||||
<span
|
||||
className="text-sm font-medium"
|
||||
style={{ color: 'var(--accent-bright)' }}
|
||||
>
|
||||
관리자
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!menu) return null
|
||||
|
||||
return (
|
||||
|
|
@ -102,8 +121,93 @@ function ThemeToggle() {
|
|||
)
|
||||
}
|
||||
|
||||
function LoginButton({ onClick }) {
|
||||
const apiKey = useAuthStore((s) => s.apiKey)
|
||||
const loggedIn = !!apiKey
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={loggedIn ? 'API 키 관리' : 'API 키 로그인'}
|
||||
title={loggedIn ? 'API 키 관리' : 'API 키 로그인'}
|
||||
className="inline-flex items-center gap-1.5 rounded-full border px-3 h-8 text-xs font-medium hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
background: 'var(--toggle-bg)',
|
||||
borderColor: loggedIn ? 'var(--selected-border)' : 'var(--toggle-border)',
|
||||
color: loggedIn ? 'var(--accent-bright)' : 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 7C15 5.34 13.66 4 12 4C10.34 4 9 5.34 9 7C9 7.74 9.27 8.42 9.71 8.95L4 14.66V20H9.34L15.05 14.29C15.58 14.73 16.26 15 17 15C18.66 15 20 13.66 20 12M17 8.5C16.72 8.5 16.5 8.28 16.5 8C16.5 7.72 16.72 7.5 17 7.5C17.28 7.5 17.5 7.72 17.5 8C17.5 8.28 17.28 8.5 17 8.5Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
{loggedIn ? '로그인됨' : '로그인'}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border px-3 h-8 text-xs font-medium hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
background: 'var(--toggle-bg)',
|
||||
borderColor: 'var(--toggle-border)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
title="관리자 페이지"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 15a3 3 0 100-6 3 3 0 000 6z" stroke="currentColor" strokeWidth="1.8" />
|
||||
<path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 11-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 110-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 114 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 110 4h-.09a1.65 1.65 0 00-1.51 1z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
관리자
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function HomeLinkButton() {
|
||||
const isAdminRoute = !!useMatch('/admin/*')
|
||||
if (!isAdminRoute) return null
|
||||
|
||||
return (
|
||||
<Link
|
||||
to="/"
|
||||
className="inline-flex items-center gap-1.5 rounded-full border px-3 h-8 text-xs font-medium hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
background: 'var(--toggle-bg)',
|
||||
borderColor: 'var(--toggle-border)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
title="홈으로"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M3 10.5L12 3l9 7.5V21a1 1 0 01-1 1h-5v-7h-6v7H4a1 1 0 01-1-1V10.5z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
홈으로
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
|||
</Link>
|
||||
<CurrentMenuTitle />
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
<div className="flex items-center gap-2">
|
||||
<LoginButton onClick={() => setLoginOpen(true)} />
|
||||
<AdminLinkButton />
|
||||
<HomeLinkButton />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
||||
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
|
||||
fullscreen ? 'min-h-0 px-6 py-4' : 'px-6 pt-4 pb-10'
|
||||
}`}>
|
||||
|
|
|
|||
170
frontend/src/components/LoginDialog.jsx
Normal file
170
frontend/src/components/LoginDialog.jsx
Normal file
|
|
@ -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 (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-md"
|
||||
style={{ background: 'var(--dialog-backdrop)' }}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog"
|
||||
initial={{ opacity: 0, scale: 0.94, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 4 }}
|
||||
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="w-full max-w-md rounded-2xl border shadow-2xl ring-1"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
||||
borderColor: 'var(--dialog-border)',
|
||||
'--tw-ring-color': 'var(--ring-info)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="px-7 pt-7 pb-3 flex items-start gap-4">
|
||||
<div
|
||||
className="shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center"
|
||||
style={{
|
||||
background: 'var(--icon-info-bg)',
|
||||
borderColor: 'var(--icon-info-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
}}
|
||||
>
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 7C15 5.34 13.66 4 12 4C10.34 4 9 5.34 9 7C9 7.74 9.27 8.42 9.71 8.95L4 14.66V20H9.34L15.05 14.29C15.58 14.73 16.26 15 17 15C18.66 15 20 13.66 20 12M17 8.5C16.72 8.5 16.5 8.28 16.5 8C16.5 7.72 16.72 7.5 17 7.5C17.28 7.5 17.5 7.72 17.5 8C17.5 8.28 17.28 8.5 17 8.5Z" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-bold pt-1" style={{ color: 'var(--text-strong)' }}>API 키 로그인</h3>
|
||||
<p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
|
||||
NEXON Open API 키를 입력하면 계정의 캐릭터 목록을 불러올 수 있습니다
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg hover:bg-[var(--row-hover-bg)] flex items-center justify-center text-xl leading-none"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-7 py-4 space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => { 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 && (
|
||||
<p className="text-xs" style={{ color: 'var(--danger-text)' }}>{error}</p>
|
||||
)}
|
||||
<p className="text-xs leading-relaxed" style={{ color: 'var(--text-dim)' }}>
|
||||
키는 브라우저에만 저장되며 서버로 전송되지 않습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-2 px-7 py-4 border-t"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
{apiKey ? (
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex-1 rounded-lg border px-4 h-11 text-sm font-medium hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
||||
style={{
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
로그아웃
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border px-4 h-11 text-sm font-medium hover:bg-[var(--btn-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={busy}
|
||||
className="flex-1 rounded-lg px-4 h-11 text-sm font-semibold disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
>
|
||||
{busy ? '확인 중...' : apiKey ? '업데이트' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className={`w-full ${maxWidth} rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col`} onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
||||
style={{ background: 'var(--dialog-backdrop)' }}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${maxWidth} rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col`}
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
|
||||
borderColor: 'var(--dialog-border)',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
|
|
@ -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)',
|
||||
}}
|
||||
>
|
||||
<div className="text-2xl mb-1 opacity-50">📥</div>
|
||||
<p className="text-sm text-gray-400">클릭하거나 이미지를 끌어다 놓으세요</p>
|
||||
<p className="text-xs text-gray-600 mt-0.5">여러 개 선택 가능</p>
|
||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
|
||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 개 선택 가능</p>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
|
|
@ -111,14 +137,22 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
|||
: null
|
||||
|
||||
return (
|
||||
<div key={item.id} className={`flex items-start gap-3 rounded-lg border bg-gray-950/50 p-2 ${
|
||||
errorMsg ? 'border-red-500/40' : 'border-white/5'
|
||||
}`}>
|
||||
<div className="w-12 h-12 rounded bg-gray-900 flex items-center justify-center overflow-hidden shrink-0">
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-start gap-3 rounded-lg border p-2"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-12 h-12 rounded flex items-center justify-center overflow-hidden shrink-0"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
{item.preview ? (
|
||||
<img src={item.preview} alt="" className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="w-4 h-4 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-0.5">
|
||||
|
|
@ -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 && <div className="text-[11px] text-red-400 px-0.5">{errorMsg}</div>}
|
||||
{errorMsg && (
|
||||
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(item.id)}
|
||||
className="w-7 h-7 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition shrink-0"
|
||||
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
|
@ -147,14 +187,31 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
|||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex gap-2 px-6 py-4 border-t border-white/5 shrink-0">
|
||||
<button type="button" onClick={onClose} className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition">
|
||||
<div
|
||||
className="flex gap-2 px-6 py-4 border-t shrink-0"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!canSubmit || uploading}
|
||||
className="flex-1 rounded-lg bg-emerald-600 px-4 py-2 text-sm font-medium hover:bg-emerald-500 disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
className="flex-1 rounded-lg px-4 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-primary-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
>
|
||||
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}개 ` : ''}업로드`}
|
||||
</button>
|
||||
|
|
@ -169,22 +226,32 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
|||
return (
|
||||
<div
|
||||
onClick={() => 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 && (
|
||||
<div className={`absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition ${
|
||||
selected ? 'border-emerald-500 bg-emerald-500' : 'border-white/30 bg-gray-950/80'
|
||||
}`}>
|
||||
{selected && <span className="text-xs text-white">✓</span>}
|
||||
<div
|
||||
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
|
||||
style={selected ? {
|
||||
borderColor: 'var(--accent)',
|
||||
background: 'var(--accent)',
|
||||
} : {
|
||||
borderColor: 'var(--panel-border)',
|
||||
background: 'var(--surface-3)',
|
||||
}}
|
||||
>
|
||||
{selected && <span className="text-xs" style={{ color: 'var(--btn-primary-text)' }}>✓</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-4 relative">
|
||||
<div
|
||||
className="aspect-square flex items-center justify-center p-4 relative"
|
||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
||||
>
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.name}
|
||||
|
|
@ -196,7 +263,12 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
|||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
|
||||
className="w-7 h-7 rounded-md bg-gray-950/80 backdrop-blur-sm border border-white/10 hover:bg-emerald-500/20 hover:border-emerald-500/40 text-xs flex items-center justify-center transition"
|
||||
className="w-7 h-7 rounded-md backdrop-blur-sm border text-xs flex items-center justify-center hover:bg-[var(--selected-bg)] hover:border-[var(--selected-border)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
title="URL 복사"
|
||||
>
|
||||
{copied ? '✓' : '⧉'}
|
||||
|
|
@ -205,7 +277,10 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
|||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2 border-t border-white/5">
|
||||
<div
|
||||
className="px-3 py-2 border-t"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<div className="text-sm font-medium truncate">{image.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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 (
|
||||
<div className="flex items-center justify-center gap-1 pt-2">
|
||||
<button
|
||||
onClick={() => onChange(page - 1)}
|
||||
disabled={page === 1}
|
||||
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={btnStyle}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
|
||||
{start > 1 && (
|
||||
<>
|
||||
<button onClick={() => onChange(1)} className={`${btn} border border-white/10 hover:bg-white/5`}>1</button>
|
||||
{start > 2 && <span className="text-gray-600 px-1">…</span>}
|
||||
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
|
||||
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
||||
</>
|
||||
)}
|
||||
|
||||
{pages.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onChange(p)}
|
||||
className={`${btn} ${
|
||||
p === page
|
||||
? 'bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 font-medium'
|
||||
: 'border border-white/10 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
))}
|
||||
{pages.map((p) => {
|
||||
const active = p === page
|
||||
return (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => onChange(p)}
|
||||
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : btnStyle}
|
||||
>
|
||||
{p}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
{end < totalPages && (
|
||||
<>
|
||||
{end < totalPages - 1 && <span className="text-gray-600 px-1">…</span>}
|
||||
<button onClick={() => onChange(totalPages)} className={`${btn} border border-white/10 hover:bg-white/5`}>{totalPages}</button>
|
||||
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
||||
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => onChange(page + 1)}
|
||||
disabled={page === totalPages}
|
||||
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||
style={btnStyle}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
|
|
@ -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() {
|
|||
<div className="space-y-6">
|
||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">이미지 관리</h2>
|
||||
<p className="text-sm text-gray-500 mt-0.5">공용 이미지를 업로드하고 관리합니다</p>
|
||||
<h2 className="text-lg font-medium">이미지 관리</h2>
|
||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{selectMode ? (
|
||||
<>
|
||||
<span className="text-sm text-gray-400">{selectedIds.size}개 선택</span>
|
||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size}개 선택</span>
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
|
||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
|
||||
</button>
|
||||
<button
|
||||
onClick={requestDelete}
|
||||
disabled={selectedIds.size === 0}
|
||||
className="rounded-lg bg-red-600 hover:bg-red-500 px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed transition shadow-lg shadow-red-500/20"
|
||||
className="rounded-lg px-3 py-2 text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed hover:bg-[var(--btn-danger-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-danger-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-danger-shadow)',
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleSelectMode}
|
||||
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
|
||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-bg)',
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
완료
|
||||
</button>
|
||||
|
|
@ -436,14 +532,23 @@ export default function AdminImages() {
|
|||
{images.length > 0 && (
|
||||
<button
|
||||
onClick={toggleSelectMode}
|
||||
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-3 py-2 text-sm transition"
|
||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
|
||||
style={{
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
|
||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
||||
style={{
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
}}
|
||||
>
|
||||
<span className="text-base leading-none">+</span>
|
||||
이미지 업로드
|
||||
|
|
@ -461,9 +566,14 @@ export default function AdminImages() {
|
|||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="이미지 이름으로 검색..."
|
||||
className="w-full rounded-lg border border-white/10 bg-gray-900/50 pl-10 pr-4 py-2.5 text-sm outline-none focus:border-emerald-500/50 transition"
|
||||
className="w-full rounded-lg border pl-10 pr-4 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
|
||||
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -471,19 +581,30 @@ export default function AdminImages() {
|
|||
{isLoading ? (
|
||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded-xl bg-white/[0.02] animate-pulse" />
|
||||
<div
|
||||
key={i}
|
||||
className="aspect-square rounded-xl animate-pulse"
|
||||
style={{ background: 'var(--skeleton-bg)' }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : images.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||
<div
|
||||
className="rounded-2xl border border-dashed p-16 text-center"
|
||||
style={{
|
||||
borderColor: 'var(--dashed-border)',
|
||||
background: 'var(--skeleton-bg)',
|
||||
}}
|
||||
>
|
||||
<div className="text-5xl mb-3 opacity-30">🖼️</div>
|
||||
<p className="text-gray-400 mb-4">
|
||||
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
|
||||
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
||||
</p>
|
||||
{!debouncedSearch && (
|
||||
<button
|
||||
onClick={() => setUploadOpen(true)}
|
||||
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
|
||||
className="text-sm hover:text-[var(--accent-hover-text)]"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
>
|
||||
첫 이미지 업로드하기 →
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="flex items-center justify-center pt-20">
|
||||
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!verified) {
|
||||
if (key) localStorage.removeItem('maple-admin-key')
|
||||
return <Navigate to="/" replace />
|
||||
}
|
||||
if (!verified) return <Navigate to="/" replace />
|
||||
|
||||
return <Outlet context={{ handleLogout }} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
}}
|
||||
/>
|
||||
<CharacterSuggestDropdown
|
||||
open={dropdownOpen}
|
||||
filter={name}
|
||||
excludeNames={characters.map((c) => c.character_name)}
|
||||
onSelect={(n) => {
|
||||
setName(n)
|
||||
setDropdownOpen(false)
|
||||
setError('')
|
||||
searchMutation.mutate(n)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { api } from '../../api/client'
|
|||
import { useLayout } from '../../components/Layout'
|
||||
import Select from '../../components/Select'
|
||||
import Tooltip from '../../components/Tooltip'
|
||||
import CharacterSuggestDropdown from '../../components/CharacterSuggestDropdown'
|
||||
import { useSymbolStore } from './store'
|
||||
|
||||
dayjs.extend(utc)
|
||||
|
|
@ -469,6 +470,7 @@ export default function Symbol() {
|
|||
|
||||
const [addName, setAddName] = useState('')
|
||||
const [addError, setAddError] = useState('')
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
||||
|
||||
const symbols = allSymbols.filter((s) => s.type === tab)
|
||||
const tabInfo = tabs.find((t) => t.key === tab)
|
||||
|
|
@ -570,6 +572,8 @@ export default function Symbol() {
|
|||
type="text"
|
||||
value={addName}
|
||||
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
|
||||
onFocus={() => setDropdownOpen(true)}
|
||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
||||
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
|
||||
className="w-full h-12 box-border rounded-lg border pl-10 pr-4 text-base outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
|
|
@ -578,6 +582,17 @@ export default function Symbol() {
|
|||
color: 'var(--text-strong)',
|
||||
}}
|
||||
/>
|
||||
<CharacterSuggestDropdown
|
||||
open={dropdownOpen}
|
||||
filter={addName}
|
||||
excludeNames={characters.map((c) => c.character_name)}
|
||||
onSelect={(n) => {
|
||||
setAddName(n)
|
||||
setDropdownOpen(false)
|
||||
setAddError('')
|
||||
searchMutation.mutate(n)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
|
|
|
|||
11
frontend/src/stores/auth.js
Normal file
11
frontend/src/stores/auth.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
export const useAuthStore = create(persist(
|
||||
(set) => ({
|
||||
apiKey: '',
|
||||
setApiKey: (k) => set({ apiKey: k }),
|
||||
clearApiKey: () => set({ apiKey: '' }),
|
||||
}),
|
||||
{ name: 'maple-auth' },
|
||||
))
|
||||
Loading…
Add table
Reference in a new issue