Compare commits
No commits in common. "45d325dfbedee17f3c00610498cbb1f0b1e894e0" and "1dfceaf350aabe802f0d69ee01c6209742b641c7" have entirely different histories.
45d325dfbe
...
1dfceaf350
23 changed files with 319 additions and 1248 deletions
|
|
@ -1,8 +1,5 @@
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { Op } from 'sequelize';
|
|
||||||
import { Image } from '../models/index.js';
|
|
||||||
import { getPublicUrl } from '../lib/s3.js';
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
const NEXON_API_BASE = 'https://open.api.nexon.com';
|
||||||
|
|
@ -81,68 +78,4 @@ 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;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,9 @@
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
export async function api(url, options = {}) {
|
export async function api(url, options = {}) {
|
||||||
const headers = { 'Content-Type': 'application/json', ...options.headers }
|
const headers = { 'Content-Type': 'application/json', ...options.headers }
|
||||||
|
|
||||||
// 관리자 API에는 로그인 다이얼로그에서 저장한 키를 자동으로 헤더에 포함
|
// 관리자 API에는 인증 헤더 자동 추가
|
||||||
if (url.startsWith('/api/admin')) {
|
if (url.startsWith('/api/admin')) {
|
||||||
const adminKey = useAuthStore.getState().apiKey
|
const adminKey = localStorage.getItem('maple-admin-key')
|
||||||
if (adminKey) headers['x-admin-key'] = adminKey
|
if (adminKey) headers['x-admin-key'] = adminKey
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,109 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -14,17 +14,11 @@ export default function Checkbox({ checked, onChange, disabled, className = '',
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
||||||
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center ${
|
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center transition ${
|
||||||
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
|
checked
|
||||||
} ${className}`}
|
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||||
style={checked ? {
|
: 'border-white/20 bg-gray-950 hover:border-white/40'
|
||||||
borderColor: 'var(--accent)',
|
} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} ${className}`}
|
||||||
background: 'var(--accent)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
} : {
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{checked && (
|
{checked && (
|
||||||
<svg className={iconSize} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg className={iconSize} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,7 @@ import { Outlet, Link, useLocation, useMatch } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import Footer from './Footer'
|
import Footer from './Footer'
|
||||||
import LoginDialog from './LoginDialog'
|
|
||||||
import { useThemeStore } from '../stores/theme'
|
import { useThemeStore } from '../stores/theme'
|
||||||
import { useAuthStore } from '../stores/auth'
|
|
||||||
|
|
||||||
const SITE_NAME = '메이플스토리 유틸리티'
|
const SITE_NAME = '메이플스토리 유틸리티'
|
||||||
|
|
||||||
|
|
@ -40,23 +38,6 @@ function CurrentMenuTitle() {
|
||||||
}
|
}
|
||||||
}, [isAdmin, menu])
|
}, [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
|
if (!menu) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -121,100 +102,12 @@ 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() {
|
export default function Layout() {
|
||||||
const location = useLocation()
|
|
||||||
const [fullscreen, setFullscreen] = useState(false)
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
const [loginOpen, setLoginOpen] = useState(false)
|
|
||||||
const isAdmin = !!useMatch('/admin/*')
|
const isAdmin = !!useMatch('/admin/*')
|
||||||
const homeTo = isAdmin ? '/admin' : '/'
|
const homeTo = isAdmin ? '/admin' : '/'
|
||||||
const theme = useThemeStore((s) => s.theme)
|
const theme = useThemeStore((s) => s.theme)
|
||||||
|
|
||||||
const isHome = location.pathname === '/'
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement
|
const root = document.documentElement
|
||||||
if (theme === 'light') root.setAttribute('data-theme', 'light')
|
if (theme === 'light') root.setAttribute('data-theme', 'light')
|
||||||
|
|
@ -245,21 +138,15 @@ export default function Layout() {
|
||||||
</Link>
|
</Link>
|
||||||
<CurrentMenuTitle />
|
<CurrentMenuTitle />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<ThemeToggle />
|
||||||
<LoginButton onClick={() => setLoginOpen(true)} />
|
|
||||||
<AdminLinkButton />
|
|
||||||
<HomeLinkButton />
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<LoginDialog open={loginOpen} onClose={() => setLoginOpen(false)} />
|
|
||||||
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
|
<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'
|
fullscreen ? 'min-h-0 px-6 py-4' : 'px-6 pt-4 pb-10'
|
||||||
}`}>
|
}`}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{isHome && <Footer />}
|
{!fullscreen && <Footer />}
|
||||||
</div>
|
</div>
|
||||||
</LayoutContext.Provider>
|
</LayoutContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,170 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,16 +1,8 @@
|
||||||
export default function AdminBoss() {
|
export default function AdminBoss() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h2 className="text-lg font-medium">보스 수익 계산기 관리</h2>
|
<h2 className="text-lg font-semibold">보스 수익 계산기 관리</h2>
|
||||||
<div
|
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
|
||||||
className="rounded-lg border p-8 text-center"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
color: 'var(--text-dim)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
준비 중
|
준비 중
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -17,29 +17,22 @@ export default function AdminFeaturePage() {
|
||||||
|
|
||||||
if (!Component) {
|
if (!Component) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-5xl mx-auto pt-6">
|
<div className="space-y-4">
|
||||||
{menu && (
|
{menu && (
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{menu.title}</h2>
|
<h2 className="text-lg font-semibold">{menu.title}</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>{menu.description}</p>
|
<p className="text-sm text-gray-500 mt-0.5">{menu.description}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-12 text-center">
|
||||||
className="rounded-2xl border border-dashed p-12 text-center"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--dashed-border)',
|
|
||||||
background: 'var(--skeleton-bg)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-4xl mb-3 opacity-30">🛠️</div>
|
<div className="text-4xl mb-3 opacity-30">🛠️</div>
|
||||||
<p style={{ color: 'var(--text-muted)' }}>이 기능에는 관리 페이지가 없습니다</p>
|
<p className="text-gray-400">이 기능에는 관리 페이지가 없습니다</p>
|
||||||
<p className="text-xs mt-2 font-mono" style={{ color: 'var(--text-dim)' }}>
|
<p className="text-xs text-gray-600 mt-2 font-mono">
|
||||||
features/{slug}/{slug.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')}Admin.jsx
|
features/{slug}/{slug.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')}Admin.jsx
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
to={`/admin/menus/${menu?.id || ''}`}
|
to={`/admin/menus/${menu?.id || ''}`}
|
||||||
className="inline-block mt-4 text-xs hover:text-[var(--accent-hover-text)]"
|
className="inline-block mt-4 text-xs text-emerald-400 hover:text-emerald-300 transition"
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
>
|
||||||
메뉴 정보 편집 →
|
메뉴 정보 편집 →
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -51,10 +44,7 @@ export default function AdminFeaturePage() {
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={
|
<Suspense fallback={
|
||||||
<div className="flex items-center justify-center pt-20">
|
<div className="flex items-center justify-center pt-20">
|
||||||
<div
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
|
|
||||||
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
}>
|
}>
|
||||||
<Component />
|
<Component />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { Link, useNavigate, useOutletContext } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
|
||||||
|
|
@ -72,6 +72,7 @@ function AddCard({ to, icon, label }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AdminHome() {
|
export default function AdminHome() {
|
||||||
|
const { handleLogout } = useOutletContext() || {}
|
||||||
const { data: menus = [], isLoading: loading } = useQuery({
|
const { data: menus = [], isLoading: loading } = useQuery({
|
||||||
queryKey: ['admin', 'menus'],
|
queryKey: ['admin', 'menus'],
|
||||||
queryFn: () => api('/api/admin/menus').catch(() => []),
|
queryFn: () => api('/api/admin/menus').catch(() => []),
|
||||||
|
|
@ -156,6 +157,18 @@ export default function AdminHome() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* 로그아웃 */}
|
||||||
|
{handleLogout && (
|
||||||
|
<div className="pt-4 text-center">
|
||||||
|
<button
|
||||||
|
onClick={handleLogout}
|
||||||
|
className="text-xs transition-colors hover:text-red-500"
|
||||||
|
style={{ color: 'var(--text-dim)' }}
|
||||||
|
>
|
||||||
|
관리자 로그아웃
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,16 @@ import { useState, useEffect } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
|
|
||||||
/* ── 공용 모달 ── */
|
/* ── 공용 모달 ── */
|
||||||
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
<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()}>
|
||||||
style={{ background: 'var(--dialog-backdrop)' }}
|
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
|
||||||
onClick={onClose}
|
<h3 className="font-semibold">{title}</h3>
|
||||||
>
|
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
|
||||||
<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>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -42,7 +21,7 @@ function Modal({ open, onClose, title, children, maxWidth = 'max-w-md' }) {
|
||||||
|
|
||||||
/* ── 업로드 모달 (다중 지원) ── */
|
/* ── 업로드 모달 (다중 지원) ── */
|
||||||
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([]) // { file, name, preview, id }
|
||||||
const [dragOver, setDragOver] = useState(false)
|
const [dragOver, setDragOver] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -102,18 +81,13 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
setDragOver(false)
|
setDragOver(false)
|
||||||
addFiles(e.dataTransfer.files)
|
addFiles(e.dataTransfer.files)
|
||||||
}}
|
}}
|
||||||
className="relative rounded-xl border-2 border-dashed cursor-pointer min-h-[120px] flex flex-col items-center justify-center"
|
className={`relative rounded-xl border-2 border-dashed transition cursor-pointer min-h-[120px] flex flex-col items-center justify-center ${
|
||||||
style={dragOver ? {
|
dragOver ? 'border-emerald-500 bg-emerald-500/10' : 'border-white/10 hover:border-white/20 bg-white/[0.02]'
|
||||||
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>
|
<div className="text-2xl mb-1 opacity-50">📥</div>
|
||||||
<p className="text-sm" style={{ color: 'var(--text-muted)' }}>클릭하거나 이미지를 끌어다 놓으세요</p>
|
<p className="text-sm text-gray-400">클릭하거나 이미지를 끌어다 놓으세요</p>
|
||||||
<p className="text-xs mt-0.5" style={{ color: 'var(--text-dim)' }}>여러 개 선택 가능</p>
|
<p className="text-xs text-gray-600 mt-0.5">여러 개 선택 가능</p>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*"
|
||||||
|
|
@ -137,22 +111,14 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={item.id} className={`flex items-start gap-3 rounded-lg border bg-gray-950/50 p-2 ${
|
||||||
key={item.id}
|
errorMsg ? 'border-red-500/40' : 'border-white/5'
|
||||||
className="flex items-start gap-3 rounded-lg border p-2"
|
}`}>
|
||||||
style={{
|
<div className="w-12 h-12 rounded bg-gray-900 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
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 ? (
|
{item.preview ? (
|
||||||
<img src={item.preview} alt="" className="w-full h-full object-contain" />
|
<img src={item.preview} alt="" className="w-full h-full object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-4 h-4 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
<div className="w-4 h-4 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 space-y-0.5">
|
<div className="flex-1 min-w-0 space-y-0.5">
|
||||||
|
|
@ -160,22 +126,16 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
type="text"
|
type="text"
|
||||||
value={item.name}
|
value={item.name}
|
||||||
onChange={(e) => updateName(item.id, e.target.value)}
|
onChange={(e) => updateName(item.id, e.target.value)}
|
||||||
className="w-full rounded border px-2 py-1.5 text-sm outline-none"
|
className={`w-full rounded border bg-gray-900 px-2 py-1.5 text-sm outline-none transition ${
|
||||||
style={{
|
errorMsg ? 'border-red-500/40 focus:border-red-500/60' : 'border-white/10 focus:border-emerald-500/50'
|
||||||
background: 'var(--input-bg)',
|
}`}
|
||||||
borderColor: errorMsg ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{errorMsg && (
|
{errorMsg && <div className="text-[11px] text-red-400 px-0.5">{errorMsg}</div>}
|
||||||
<div className="text-[11px] px-0.5" style={{ color: 'var(--danger-text)' }}>{errorMsg}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => removeItem(item.id)}
|
onClick={() => removeItem(item.id)}
|
||||||
className="w-7 h-7 rounded shrink-0 hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
|
className="w-7 h-7 rounded text-gray-500 hover:text-red-400 hover:bg-red-500/10 transition shrink-0"
|
||||||
style={{ color: 'var(--text-dim)' }}
|
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -187,31 +147,14 @@ function UploadModal({ open, onClose, onUpload, uploading, existingNames }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 버튼 */}
|
{/* 버튼 */}
|
||||||
<div
|
<div className="flex gap-2 px-6 py-4 border-t border-white/5 shrink-0">
|
||||||
className="flex gap-2 px-6 py-4 border-t 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">
|
||||||
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>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!canSubmit || uploading}
|
disabled={!canSubmit || uploading}
|
||||||
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)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}개 ` : ''}업로드`}
|
{uploading ? '업로드 중...' : `${items.length > 0 ? `${items.length}개 ` : ''}업로드`}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -226,32 +169,22 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onClick={() => selectMode && onToggle(image.id)}
|
onClick={() => selectMode && onToggle(image.id)}
|
||||||
className={`group relative rounded-xl border overflow-hidden ${selectMode ? 'cursor-pointer' : ''}`}
|
className={`group relative rounded-xl border overflow-hidden transition ${
|
||||||
style={{
|
selected
|
||||||
borderColor: selected ? 'var(--selected-border)' : 'var(--panel-border)',
|
? 'border-emerald-500/60 bg-emerald-500/5 ring-2 ring-emerald-500/30'
|
||||||
background: selected ? 'var(--selected-bg)' : 'var(--panel-bg)',
|
: 'border-white/5 bg-gray-900/40 hover:border-white/15'
|
||||||
boxShadow: selected ? '0 0 0 2px var(--ring-info)' : 'var(--panel-shadow)',
|
} ${selectMode ? 'cursor-pointer' : ''}`}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{/* 체크박스 (선택모드) */}
|
||||||
{selectMode && (
|
{selectMode && (
|
||||||
<div
|
<div className={`absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center transition ${
|
||||||
className="absolute top-2 left-2 z-10 w-5 h-5 rounded border-2 flex items-center justify-center"
|
selected ? 'border-emerald-500 bg-emerald-500' : 'border-white/30 bg-gray-950/80'
|
||||||
style={selected ? {
|
}`}>
|
||||||
borderColor: 'var(--accent)',
|
{selected && <span className="text-xs text-white">✓</span>}
|
||||||
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div
|
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-4 relative">
|
||||||
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
|
<img
|
||||||
src={image.url}
|
src={image.url}
|
||||||
alt={image.name}
|
alt={image.name}
|
||||||
|
|
@ -263,12 +196,7 @@ 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">
|
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
|
onClick={(e) => { e.stopPropagation(); onCopyUrl(image) }}
|
||||||
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)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
title="URL 복사"
|
title="URL 복사"
|
||||||
>
|
>
|
||||||
{copied ? '✓' : '⧉'}
|
{copied ? '✓' : '⧉'}
|
||||||
|
|
@ -277,10 +205,7 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className="px-3 py-2 border-t border-white/5">
|
||||||
className="px-3 py-2 border-t"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
<div className="text-sm font-medium truncate">{image.name}</div>
|
<div className="text-sm font-medium truncate">{image.name}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -298,61 +223,50 @@ function Pagination({ page, totalPages, onChange }) {
|
||||||
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
|
if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1)
|
||||||
for (let i = start; i <= end; i++) pages.push(i)
|
for (let i = start; i <= end; i++) pages.push(i)
|
||||||
|
|
||||||
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 btn = "min-w-9 h-9 px-3 rounded-lg text-sm transition flex items-center justify-center"
|
||||||
const btnStyle = {
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center gap-1 pt-2">
|
<div className="flex items-center justify-center gap-1 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(page - 1)}
|
onClick={() => onChange(page - 1)}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||||
style={btnStyle}
|
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{start > 1 && (
|
{start > 1 && (
|
||||||
<>
|
<>
|
||||||
<button onClick={() => onChange(1)} className={baseBtn} style={btnStyle}>1</button>
|
<button onClick={() => onChange(1)} className={`${btn} border border-white/10 hover:bg-white/5`}>1</button>
|
||||||
{start > 2 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
{start > 2 && <span className="text-gray-600 px-1">…</span>}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pages.map((p) => {
|
{pages.map((p) => (
|
||||||
const active = p === page
|
<button
|
||||||
return (
|
key={p}
|
||||||
<button
|
onClick={() => onChange(p)}
|
||||||
key={p}
|
className={`${btn} ${
|
||||||
onClick={() => onChange(p)}
|
p === page
|
||||||
className={`${baseBtn} ${active ? 'font-medium' : ''}`}
|
? 'bg-emerald-500/20 border border-emerald-500/40 text-emerald-300 font-medium'
|
||||||
style={active ? {
|
: 'border border-white/10 hover:bg-white/5'
|
||||||
background: 'var(--selected-bg)',
|
}`}
|
||||||
borderColor: 'var(--selected-border)',
|
>
|
||||||
color: 'var(--accent-bright)',
|
{p}
|
||||||
} : btnStyle}
|
</button>
|
||||||
>
|
))}
|
||||||
{p}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
|
|
||||||
{end < totalPages && (
|
{end < totalPages && (
|
||||||
<>
|
<>
|
||||||
{end < totalPages - 1 && <span className="px-1" style={{ color: 'var(--text-dim)' }}>…</span>}
|
{end < totalPages - 1 && <span className="text-gray-600 px-1">…</span>}
|
||||||
<button onClick={() => onChange(totalPages)} className={baseBtn} style={btnStyle}>{totalPages}</button>
|
<button onClick={() => onChange(totalPages)} className={`${btn} border border-white/10 hover:bg-white/5`}>{totalPages}</button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => onChange(page + 1)}
|
onClick={() => onChange(page + 1)}
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
className={`${baseBtn} disabled:opacity-30 disabled:cursor-not-allowed`}
|
className={`${btn} border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed`}
|
||||||
style={btnStyle}
|
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -371,9 +285,10 @@ export default function AdminImages() {
|
||||||
const [uploadOpen, setUploadOpen] = useState(false)
|
const [uploadOpen, setUploadOpen] = useState(false)
|
||||||
const [selectMode, setSelectMode] = useState(false)
|
const [selectMode, setSelectMode] = useState(false)
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
const [confirmDelete, setConfirmDelete] = useState(null)
|
const [confirmDelete, setConfirmDelete] = useState(null) // {ids, names}
|
||||||
const [copiedId, setCopiedId] = useState(null)
|
const [copiedId, setCopiedId] = useState(null)
|
||||||
|
|
||||||
|
// 검색어 디바운싱
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = setTimeout(() => {
|
const t = setTimeout(() => {
|
||||||
setDebouncedSearch(search)
|
setDebouncedSearch(search)
|
||||||
|
|
@ -382,6 +297,7 @@ export default function AdminImages() {
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [search])
|
}, [search])
|
||||||
|
|
||||||
|
// 이미지 목록 (페이징 + 검색)
|
||||||
const { data: imagesData, isLoading } = useQuery({
|
const { data: imagesData, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
queryKey: ['admin', 'images', { page, search: debouncedSearch }],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
|
|
@ -398,6 +314,7 @@ export default function AdminImages() {
|
||||||
const images = imagesData?.items || []
|
const images = imagesData?.items || []
|
||||||
const totalPages = imagesData?.total_pages || 1
|
const totalPages = imagesData?.total_pages || 1
|
||||||
|
|
||||||
|
// 전체 이름 (중복 체크용)
|
||||||
const { data: allNamesArray = [] } = useQuery({
|
const { data: allNamesArray = [] } = useQuery({
|
||||||
queryKey: ['admin', 'images', 'names'],
|
queryKey: ['admin', 'images', 'names'],
|
||||||
queryFn: () => api('/api/admin/images/names'),
|
queryFn: () => api('/api/admin/images/names'),
|
||||||
|
|
@ -408,6 +325,7 @@ export default function AdminImages() {
|
||||||
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
|
queryClient.invalidateQueries({ queryKey: ['admin', 'images'] })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 업로드
|
||||||
const uploadMutation = useMutation({
|
const uploadMutation = useMutation({
|
||||||
mutationFn: async (items) => {
|
mutationFn: async (items) => {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
|
|
@ -415,7 +333,7 @@ export default function AdminImages() {
|
||||||
formData.append('files', it.file)
|
formData.append('files', it.file)
|
||||||
formData.append('names', it.name.trim())
|
formData.append('names', it.name.trim())
|
||||||
})
|
})
|
||||||
const adminKey = useAuthStore.getState().apiKey
|
const adminKey = localStorage.getItem('maple-admin-key')
|
||||||
const res = await fetch('/api/admin/images', {
|
const res = await fetch('/api/admin/images', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'x-admin-key': adminKey },
|
headers: { 'x-admin-key': adminKey },
|
||||||
|
|
@ -464,6 +382,7 @@ export default function AdminImages() {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 삭제
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
|
mutationFn: (ids) => api('/api/admin/images/delete', { method: 'POST', body: { ids } }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|
@ -482,47 +401,32 @@ export default function AdminImages() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pt-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">이미지 관리</h2>
|
<h2 className="text-lg font-semibold">이미지 관리</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>공용 이미지를 업로드하고 관리합니다</p>
|
<p className="text-sm text-gray-500 mt-0.5">공용 이미지를 업로드하고 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{selectMode ? (
|
{selectMode ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-sm" style={{ color: 'var(--text-muted)' }}>{selectedIds.size}개 선택</span>
|
<span className="text-sm text-gray-400">{selectedIds.size}개 선택</span>
|
||||||
<button
|
<button
|
||||||
onClick={selectAll}
|
onClick={selectAll}
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
|
{selectedIds.size === images.length && images.length > 0 ? '전체 해제' : '전체 선택'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={requestDelete}
|
onClick={requestDelete}
|
||||||
disabled={selectedIds.size === 0}
|
disabled={selectedIds.size === 0}
|
||||||
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)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-danger-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-danger-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={toggleSelectMode}
|
onClick={toggleSelectMode}
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
className="rounded-lg border border-white/10 px-3 py-2 text-sm hover:bg-white/5 transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
완료
|
완료
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -532,23 +436,14 @@ export default function AdminImages() {
|
||||||
{images.length > 0 && (
|
{images.length > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={toggleSelectMode}
|
onClick={toggleSelectMode}
|
||||||
className="rounded-lg border px-3 py-2 text-sm hover:bg-[var(--danger-bg-hover)]"
|
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"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setUploadOpen(true)}
|
onClick={() => setUploadOpen(true)}
|
||||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">+</span>
|
<span className="text-base leading-none">+</span>
|
||||||
이미지 업로드
|
이미지 업로드
|
||||||
|
|
@ -566,14 +461,9 @@ export default function AdminImages() {
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="이미지 이름으로 검색..."
|
placeholder="이미지 이름으로 검색..."
|
||||||
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)]"
|
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"
|
||||||
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" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -581,30 +471,19 @@ export default function AdminImages() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
{Array.from({ length: 8 }).map((_, i) => (
|
{Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div
|
<div key={i} className="aspect-square rounded-xl bg-white/[0.02] animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-xl animate-pulse"
|
|
||||||
style={{ background: 'var(--skeleton-bg)' }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : images.length === 0 ? (
|
) : images.length === 0 ? (
|
||||||
<div
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||||
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>
|
<div className="text-5xl mb-3 opacity-30">🖼️</div>
|
||||||
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-gray-400 mb-4">
|
||||||
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
||||||
</p>
|
</p>
|
||||||
{!debouncedSearch && (
|
{!debouncedSearch && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setUploadOpen(true)}
|
onClick={() => setUploadOpen(true)}
|
||||||
className="text-sm hover:text-[var(--accent-hover-text)]"
|
className="text-sm text-emerald-400 hover:text-emerald-300 transition"
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
>
|
||||||
첫 이미지 업로드하기 →
|
첫 이미지 업로드하기 →
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
import { Outlet, Navigate } from 'react-router-dom'
|
import { useSearchParams, Outlet, Navigate } from 'react-router-dom'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useAuthStore } from '../../stores/auth'
|
|
||||||
|
|
||||||
export default function AdminLayout() {
|
export default function AdminLayout() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const apiKey = useAuthStore((s) => s.apiKey)
|
const [searchParams] = useSearchParams()
|
||||||
const clearApiKey = useAuthStore((s) => s.clearApiKey)
|
|
||||||
|
const keyFromUrl = searchParams.get('key')
|
||||||
|
const key = keyFromUrl || localStorage.getItem('maple-admin-key')
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['admin', 'verify', apiKey],
|
queryKey: ['admin', 'verify', key],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
await api('/api/admin/verify', { method: 'POST', body: { key: apiKey } })
|
if (!key) throw new Error('no key')
|
||||||
|
await api('/api/admin/verify', { method: 'POST', body: { key } })
|
||||||
|
localStorage.setItem('maple-admin-key', key)
|
||||||
return true
|
return true
|
||||||
},
|
},
|
||||||
enabled: !!apiKey,
|
enabled: !!key,
|
||||||
retry: false,
|
retry: false,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
|
@ -22,20 +25,23 @@ export default function AdminLayout() {
|
||||||
const verified = data === true
|
const verified = data === true
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
clearApiKey()
|
localStorage.removeItem('maple-admin-key')
|
||||||
queryClient.removeQueries({ queryKey: ['admin'] })
|
queryClient.removeQueries({ queryKey: ['admin'] })
|
||||||
window.location.href = '/'
|
window.location.href = '/'
|
||||||
}
|
}
|
||||||
|
|
||||||
if (apiKey && isLoading) {
|
if (key && isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center pt-20">
|
<div className="flex items-center justify-center pt-20">
|
||||||
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!verified) return <Navigate to="/" replace />
|
if (!verified) {
|
||||||
|
if (key) localStorage.removeItem('maple-admin-key')
|
||||||
|
return <Navigate to="/" replace />
|
||||||
|
}
|
||||||
|
|
||||||
return <Outlet context={{ handleLogout }} />
|
return <Outlet context={{ handleLogout }} />
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,23 +9,18 @@ function Field({ label, hint, error, required, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
|
<label className="text-sm font-medium text-gray-300">
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
{label} {required && <span className="text-red-400">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
{hint && <span className="text-xs text-gray-500">{hint}</span>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
{error && <div className="text-[11px] text-red-400">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AdminMenuForm() {
|
export default function AdminMenuForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
@ -38,18 +33,20 @@ export default function AdminMenuForm() {
|
||||||
const [form, setForm] = useState({
|
const [form, setForm] = useState({
|
||||||
title: '',
|
title: '',
|
||||||
description: '',
|
description: '',
|
||||||
slug: '',
|
slug: '', // 사용자 입력 (앞 / 제외)
|
||||||
image_id: null,
|
image_id: null,
|
||||||
image: null,
|
image: null, // 미리보기용 캐시
|
||||||
})
|
})
|
||||||
const [errors, setErrors] = useState({})
|
const [errors, setErrors] = useState({})
|
||||||
|
|
||||||
|
// 편집 모드일 때 기존 데이터 로드
|
||||||
const { data: menuData } = useQuery({
|
const { data: menuData } = useQuery({
|
||||||
queryKey: ['admin', 'menus', id],
|
queryKey: ['admin', 'menus', id],
|
||||||
queryFn: () => api(`/api/admin/menus/${id}`),
|
queryFn: () => api(`/api/admin/menus/${id}`),
|
||||||
enabled: isEdit,
|
enabled: isEdit,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// id 변경 또는 데이터 로드 시 폼 동기화
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEdit) {
|
if (!isEdit) {
|
||||||
setForm({ title: '', description: '', slug: '', image_id: null, image: null })
|
setForm({ title: '', description: '', slug: '', image_id: null, image: null })
|
||||||
|
|
@ -68,6 +65,7 @@ export default function AdminMenuForm() {
|
||||||
|
|
||||||
const update = (patch) => setForm((prev) => ({ ...prev, ...patch }))
|
const update = (patch) => setForm((prev) => ({ ...prev, ...patch }))
|
||||||
|
|
||||||
|
// slug에서 / 자동 제거 (붙여넣기 등 대비)
|
||||||
const handleSlugChange = (value) => {
|
const handleSlugChange = (value) => {
|
||||||
update({ slug: value.replace(/^\/+/, '') })
|
update({ slug: value.replace(/^\/+/, '') })
|
||||||
}
|
}
|
||||||
|
|
@ -121,55 +119,24 @@ export default function AdminMenuForm() {
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl mx-auto pt-6">
|
<div className="space-y-6 max-w-2xl mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>
|
<p className="text-sm text-gray-500 mt-0.5">홈 화면에 표시되는 카드의 정보를 설정합니다</p>
|
||||||
홈 화면에 표시되는 카드의 정보를 설정합니다
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-5 rounded-2xl border p-6"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 미리보기 */}
|
{/* 미리보기 */}
|
||||||
<div
|
<div className="rounded-xl border border-white/5 bg-gray-950/50 p-4">
|
||||||
className="rounded-xl border p-4"
|
<div className="text-xs text-gray-500 mb-3">미리보기</div>
|
||||||
style={{
|
<div className="rounded-xl border border-white/10 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5">
|
||||||
background: 'var(--surface-3)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-xs mb-3" style={{ color: 'var(--text-dim)' }}>미리보기</div>
|
|
||||||
<div
|
|
||||||
className="rounded-xl border p-5"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
|
|
||||||
borderColor: 'var(--card-border)',
|
|
||||||
boxShadow: 'var(--card-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div
|
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||||
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
|
|
||||||
borderColor: 'var(--icon-box-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={form.image?.url || '/default.png'} alt="" className="w-9 h-9 object-contain" />
|
<img src={form.image?.url || '/default.png'} alt="" className="w-9 h-9 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-medium">{form.title || '제목 없음'}</h3>
|
<h3 className="font-semibold">{form.title || '제목 없음'}</h3>
|
||||||
<p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
|
<p className="text-sm text-gray-400 mt-1">{form.description || '설명 없음'}</p>
|
||||||
{form.description || '설명 없음'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -182,7 +149,6 @@ export default function AdminMenuForm() {
|
||||||
onChange={(e) => update({ title: e.target.value })}
|
onChange={(e) => update({ title: e.target.value })}
|
||||||
placeholder="예: 주간 보스 수익 계산기"
|
placeholder="예: 주간 보스 수익 계산기"
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
|
@ -193,45 +159,26 @@ export default function AdminMenuForm() {
|
||||||
onChange={(e) => update({ description: e.target.value })}
|
onChange={(e) => update({ description: e.target.value })}
|
||||||
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
|
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="경로" required error={errors.slug}>
|
<Field label="경로" required error={errors.slug}>
|
||||||
<div
|
<div className={`flex items-stretch rounded-lg border bg-gray-950 transition focus-within:border-emerald-500/50 ${
|
||||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
errors.slug ? 'border-red-500/40' : 'border-white/10'
|
||||||
style={{
|
}`}>
|
||||||
background: 'var(--input-bg)',
|
<span className="flex items-center px-3 text-sm text-gray-500 border-r border-white/10 select-none">/</span>
|
||||||
borderColor: errors.slug ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="flex items-center px-3 text-sm border-r select-none"
|
|
||||||
style={{ color: 'var(--text-dim)', borderColor: 'var(--input-border)' }}
|
|
||||||
>
|
|
||||||
/
|
|
||||||
</span>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={form.slug}
|
value={form.slug}
|
||||||
onChange={(e) => handleSlugChange(e.target.value)}
|
onChange={(e) => handleSlugChange(e.target.value)}
|
||||||
placeholder="boss-crystal"
|
placeholder="boss-crystal"
|
||||||
className="flex-1 min-w-0 bg-transparent px-3 py-2 text-sm outline-none"
|
className="flex-1 min-w-0 bg-transparent px-3 py-2 text-sm outline-none"
|
||||||
style={{ color: 'var(--text-strong)' }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{form.slug.trim() && !errors.slug && (
|
{form.slug.trim() && !errors.slug && (
|
||||||
<div className="text-xs mt-1.5 flex items-center gap-1.5" style={{ color: 'var(--text-dim)' }}>
|
<div className="text-xs text-gray-500 mt-1.5 flex items-center gap-1.5">
|
||||||
<span>전체 URL:</span>
|
<span>전체 URL:</span>
|
||||||
<code
|
<code className="text-emerald-400 bg-gray-950/50 px-1.5 py-0.5 rounded">https://maple.caadiq.co.kr{fullUrl}</code>
|
||||||
className="px-1.5 py-0.5 rounded"
|
|
||||||
style={{
|
|
||||||
color: 'var(--accent-bright)',
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
https://maple.caadiq.co.kr{fullUrl}
|
|
||||||
</code>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -241,16 +188,12 @@ export default function AdminMenuForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPickerOpen(true)}
|
onClick={() => setPickerOpen(true)}
|
||||||
className="w-16 h-16 rounded-lg border flex items-center justify-center overflow-hidden shrink-0 cursor-pointer hover:border-[var(--selected-border)]"
|
className="w-16 h-16 rounded-lg border border-white/10 hover:border-emerald-500/40 bg-gray-950 flex items-center justify-center overflow-hidden transition shrink-0 cursor-pointer"
|
||||||
style={{
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{form.image?.url ? (
|
{form.image?.url ? (
|
||||||
<img src={form.image.url} alt="" className="max-w-[80%] max-h-[80%] object-contain" />
|
<img src={form.image.url} alt="" className="max-w-[80%] max-h-[80%] object-contain" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl" style={{ color: 'var(--text-dim)' }}>+</span>
|
<span className="text-2xl text-gray-700">+</span>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|
@ -260,14 +203,13 @@ export default function AdminMenuForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => update({ image_id: null, image: null })}
|
onClick={() => update({ image_id: null, image: null })}
|
||||||
className="text-xs mt-1 hover:text-[var(--danger-text-strong)]"
|
className="text-xs text-red-400 hover:text-red-300 transition mt-1"
|
||||||
style={{ color: 'var(--danger-text)' }}
|
|
||||||
>
|
>
|
||||||
이미지 제거
|
이미지 제거
|
||||||
</button>
|
</button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>이미지 선택</div>
|
<div className="text-sm text-gray-500">이미지 선택</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -278,11 +220,7 @@ export default function AdminMenuForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
className="rounded-lg border px-4 py-2.5 text-sm hover:bg-[var(--danger-bg-hover)]"
|
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-4 py-2.5 text-sm transition"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -291,24 +229,14 @@ export default function AdminMenuForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('/admin')}
|
onClick={() => navigate('/admin')}
|
||||||
className="rounded-lg border px-5 py-2.5 text-sm hover:bg-[var(--btn-bg-hover)]"
|
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="rounded-lg px-5 py-2.5 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
|
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ import { api } from '../../../api/client'
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
const PAGE_SIZE = 24
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업로드된 이미지 중 하나를 선택하는 모달 피커
|
||||||
|
*/
|
||||||
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
|
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
|
||||||
const [page, setPage] = useState(1)
|
const [page, setPage] = useState(1)
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
|
@ -45,31 +48,11 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
|
||||||
if (!open) return null
|
if (!open) return null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
|
<div className="w-full max-w-3xl rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
|
||||||
style={{ background: 'var(--dialog-backdrop)' }}
|
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
|
||||||
onClick={onClose}
|
<h3 className="font-semibold">이미지 선택</h3>
|
||||||
>
|
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
|
||||||
<div
|
|
||||||
className="w-full max-w-3xl 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)' }}>이미지 선택</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>
|
</div>
|
||||||
|
|
||||||
{/* 검색 */}
|
{/* 검색 */}
|
||||||
|
|
@ -80,14 +63,9 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
placeholder="이미지 이름으로 검색..."
|
placeholder="이미지 이름으로 검색..."
|
||||||
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)]"
|
className="w-full rounded-lg border border-white/10 bg-gray-950 pl-10 pr-4 py-2.5 text-sm outline-none focus:border-emerald-500/50 transition"
|
||||||
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" style={{ color: 'var(--input-icon)' }}>🔍</span>
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -96,84 +74,55 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
{Array.from({ length: 12 }).map((_, i) => (
|
{Array.from({ length: 12 }).map((_, i) => (
|
||||||
<div
|
<div key={i} className="aspect-square rounded-lg bg-white/[0.02] animate-pulse" />
|
||||||
key={i}
|
|
||||||
className="aspect-square rounded-lg animate-pulse"
|
|
||||||
style={{ background: 'var(--skeleton-bg)' }}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : images.length === 0 ? (
|
) : images.length === 0 ? (
|
||||||
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
|
<div className="py-12 text-center text-gray-500 text-sm">
|
||||||
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||||
{images.map((image) => {
|
{images.map((image) => (
|
||||||
const isSelected = currentImageId === image.id
|
<button
|
||||||
return (
|
key={image.id}
|
||||||
<button
|
type="button"
|
||||||
key={image.id}
|
onClick={() => { onSelect(image); onClose() }}
|
||||||
type="button"
|
className={`group rounded-lg border overflow-hidden transition ${
|
||||||
onClick={() => { onSelect(image); onClose() }}
|
currentImageId === image.id
|
||||||
className="group rounded-lg border overflow-hidden"
|
? 'border-emerald-500/60 ring-2 ring-emerald-500/30'
|
||||||
style={{
|
: 'border-white/5 hover:border-white/20'
|
||||||
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
|
}`}
|
||||||
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
|
title={image.name}
|
||||||
}}
|
>
|
||||||
title={image.name}
|
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-3">
|
||||||
>
|
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
|
||||||
<div
|
</div>
|
||||||
className="aspect-square flex items-center justify-center p-3"
|
<div className="px-2 py-1.5 border-t border-white/5 bg-gray-950/50">
|
||||||
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
|
<div className="text-xs truncate">{image.name}</div>
|
||||||
>
|
</div>
|
||||||
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
|
</button>
|
||||||
</div>
|
))}
|
||||||
<div
|
|
||||||
className="px-2 py-1.5 border-t"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="text-xs truncate">{image.name}</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이지네이션 + 액션 */}
|
{/* 페이지네이션 + 액션 */}
|
||||||
<div
|
<div className="px-6 py-4 border-t border-white/5 flex items-center justify-between shrink-0 gap-3">
|
||||||
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3"
|
|
||||||
style={{ borderColor: 'var(--panel-border)' }}
|
|
||||||
>
|
|
||||||
{totalPages > 1 ? (
|
{totalPages > 1 ? (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
disabled={page === 1}
|
disabled={page === 1}
|
||||||
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
className="w-8 h-8 rounded border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
|
<span className="text-xs text-gray-400 px-2">{page} / {totalPages}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
disabled={page === totalPages}
|
disabled={page === totalPages}
|
||||||
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
className="w-8 h-8 rounded border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -184,8 +133,7 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => { onSelect(null); onClose() }}
|
onClick={() => { onSelect(null); onClose() }}
|
||||||
className="text-sm hover:text-[var(--danger-text-strong)]"
|
className="text-sm text-red-400 hover:text-red-300 transition"
|
||||||
style={{ color: 'var(--danger-text)' }}
|
|
||||||
>
|
>
|
||||||
이미지 제거
|
이미지 제거
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useEffect, useLayoutEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../components/Layout'
|
||||||
|
|
@ -21,7 +21,7 @@ export default function BossCrystal() {
|
||||||
|
|
||||||
// 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
|
// 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
setFullscreen(true)
|
setFullscreen(true)
|
||||||
return () => setFullscreen(false)
|
return () => setFullscreen(false)
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { api } from '../../../api/client'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||||
import Checkbox from '../../../components/Checkbox'
|
import Checkbox from '../../../components/Checkbox'
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../components/Select'
|
||||||
import { useAuthStore } from '../../../stores/auth'
|
|
||||||
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
||||||
|
|
||||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
|
|
@ -14,23 +13,18 @@ function Field({ label, hint, error, required, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
|
<label className="text-sm font-medium text-gray-300">
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
{label} {required && <span className="text-red-400">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
{hint && <span className="text-xs text-gray-500">{hint}</span>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
{error && <div className="text-[11px] text-red-400">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyDifficultyState() {
|
function emptyDifficultyState() {
|
||||||
const obj = {}
|
const obj = {}
|
||||||
|
|
@ -140,7 +134,7 @@ export default function BossForm() {
|
||||||
}))
|
}))
|
||||||
formData.append('difficulties', JSON.stringify(diffsPayload))
|
formData.append('difficulties', JSON.stringify(diffsPayload))
|
||||||
|
|
||||||
const adminKey = useAuthStore.getState().apiKey
|
const adminKey = localStorage.getItem('maple-admin-key')
|
||||||
const url = isEdit
|
const url = isEdit
|
||||||
? `/api/admin/boss-crystal/bosses/${id}`
|
? `/api/admin/boss-crystal/bosses/${id}`
|
||||||
: '/api/admin/boss-crystal/bosses'
|
: '/api/admin/boss-crystal/bosses'
|
||||||
|
|
@ -180,21 +174,13 @@ export default function BossForm() {
|
||||||
const displayImage = imagePreview || existingImageUrl
|
const displayImage = imagePreview || existingImageUrl
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-2xl mx-auto pt-6">
|
<div className="space-y-6 max-w-2xl mx-auto">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{isEdit ? '보스 편집' : '보스 추가'}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? '보스 편집' : '보스 추가'}</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>보스 이름과 난이도별 결정 정보를 입력합니다</p>
|
<p className="text-sm text-gray-500 mt-0.5">보스 이름과 난이도별 결정 정보를 입력합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form
|
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
|
||||||
onSubmit={handleSubmit}
|
|
||||||
className="space-y-5 rounded-2xl border p-6"
|
|
||||||
style={{
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 이름 + 최대 인원 */}
|
{/* 이름 + 최대 인원 */}
|
||||||
<div className="grid grid-cols-[1fr_auto] gap-3">
|
<div className="grid grid-cols-[1fr_auto] gap-3">
|
||||||
<Field label="보스 이름" required error={errors.name}>
|
<Field label="보스 이름" required error={errors.name}>
|
||||||
|
|
@ -204,7 +190,6 @@ export default function BossForm() {
|
||||||
onChange={(e) => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="예: 검은 마법사"
|
placeholder="예: 검은 마법사"
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="최대 인원">
|
<Field label="최대 인원">
|
||||||
|
|
@ -220,32 +205,26 @@ export default function BossForm() {
|
||||||
{/* 이미지 */}
|
{/* 이미지 */}
|
||||||
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||||
<label
|
<label
|
||||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
className={`flex items-center gap-4 rounded-xl border-2 border-dashed bg-gray-950/50 p-4 transition cursor-pointer ${
|
||||||
style={{
|
errors.image
|
||||||
background: 'var(--surface-3)',
|
? 'border-red-500/40'
|
||||||
borderColor: errors.image ? 'var(--icon-danger-border)' : 'var(--dashed-border)',
|
: 'border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5'
|
||||||
}}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
className="w-32 h-32 rounded-lg border flex items-center justify-center overflow-hidden shrink-0"
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface-nested)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayImage ? (
|
{displayImage ? (
|
||||||
<img src={displayImage} alt="" className="w-full h-full object-cover" />
|
<img src={displayImage} alt="" className="w-full h-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl" style={{ color: 'var(--text-dim)' }}>+</span>
|
<span className="text-5xl text-gray-700">+</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
|
<div className="text-sm font-medium text-gray-300">
|
||||||
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-dim)' }}>PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
||||||
{imageFile && (
|
{imageFile && (
|
||||||
<div className="text-xs mt-2 truncate" style={{ color: 'var(--accent-bright)' }}>📎 {imageFile.name}</div>
|
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
|
|
@ -267,14 +246,12 @@ export default function BossForm() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={d.key}
|
key={d.key}
|
||||||
className="rounded-lg border p-3"
|
className={`rounded-lg border bg-gray-950/50 p-3 transition ${
|
||||||
style={{
|
v.enabled ? 'border-white/10' : 'border-white/5 opacity-60'
|
||||||
background: 'var(--surface-3)',
|
}`}
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
opacity: v.enabled ? 1 : 0.6,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 체크박스 + 난이도 이미지 (이미지 클릭으로도 토글 가능) */}
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2.5 shrink-0 cursor-pointer select-none"
|
className="flex items-center gap-2.5 shrink-0 cursor-pointer select-none"
|
||||||
onClick={() => updateDifficulty(d.key, { enabled: !v.enabled })}
|
onClick={() => updateDifficulty(d.key, { enabled: !v.enabled })}
|
||||||
|
|
@ -292,6 +269,7 @@ export default function BossForm() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 가격 */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
|
|
@ -304,18 +282,12 @@ export default function BossForm() {
|
||||||
}}
|
}}
|
||||||
disabled={!v.enabled}
|
disabled={!v.enabled}
|
||||||
placeholder="결정 가격"
|
placeholder="결정 가격"
|
||||||
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50 disabled:cursor-not-allowed"
|
className={`w-full rounded-lg border bg-gray-900 pl-4 pr-28 py-2 text-sm outline-none focus:border-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition ${
|
||||||
style={{
|
priceErr ? 'border-red-500/40' : 'border-white/10'
|
||||||
background: 'var(--input-bg)',
|
}`}
|
||||||
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{v.crystal_price && v.enabled && (
|
{v.crystal_price && v.enabled && (
|
||||||
<span
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-emerald-400/80 pointer-events-none whitespace-nowrap">
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none whitespace-nowrap"
|
|
||||||
style={{ color: 'var(--accent-bright)' }}
|
|
||||||
>
|
|
||||||
{formatMeso(Number(v.crystal_price))}
|
{formatMeso(Number(v.crystal_price))}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -328,16 +300,13 @@ export default function BossForm() {
|
||||||
</div>
|
</div>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
className="rounded-lg border px-4 py-2.5 text-sm hover:bg-[var(--danger-bg-hover)]"
|
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-4 py-2.5 text-sm transition"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -346,24 +315,14 @@ export default function BossForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('..')}
|
onClick={() => navigate('..')}
|
||||||
className="rounded-lg border px-5 py-2.5 text-sm hover:bg-[var(--btn-bg-hover)]"
|
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="rounded-lg px-5 py-2.5 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
|
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,13 @@ import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
|
||||||
|
|
||||||
function BossCardContent({ boss, dragging = false }) {
|
function BossCardContent({ boss, dragging = false }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
||||||
className="flex items-stretch rounded-2xl border"
|
dragging
|
||||||
style={{
|
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
|
: 'border-white/5'
|
||||||
borderColor: dragging ? 'var(--selected-border)' : 'var(--card-border)',
|
}`}>
|
||||||
boxShadow: dragging ? '0 12px 32px rgba(16, 185, 129, 0.25)' : 'var(--card-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 핸들 자리 */}
|
{/* 핸들 자리 */}
|
||||||
<div className="flex items-center px-2 cursor-grab active:cursor-grabbing" style={{ color: 'var(--text-dim)' }}>
|
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
|
||||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
||||||
<circle cx="4" cy="4" r="1.5" />
|
<circle cx="4" cy="4" r="1.5" />
|
||||||
<circle cx="10" cy="4" r="1.5" />
|
<circle cx="10" cy="4" r="1.5" />
|
||||||
|
|
@ -37,19 +34,13 @@ function BossCardContent({ boss, dragging = false }) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
||||||
<div
|
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||||
className="shrink-0 w-14 h-14 rounded-xl border flex items-center justify-center overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
|
|
||||||
borderColor: 'var(--icon-box-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
|
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2">
|
<div className="flex items-baseline gap-2">
|
||||||
<h3 className="font-medium truncate">{boss.name}</h3>
|
<h3 className="font-semibold truncate">{boss.name}</h3>
|
||||||
<span className="text-xs shrink-0" style={{ color: 'var(--text-dim)' }}>최대 {boss.max_party_size}인</span>
|
<span className="text-xs text-gray-500 shrink-0">최대 {boss.max_party_size}인</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1 mt-2">
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
||||||
|
|
@ -89,15 +80,17 @@ function SortableBossCard({ boss }) {
|
||||||
style={style}
|
style={style}
|
||||||
className={`relative ${isDragging ? 'opacity-30' : ''}`}
|
className={`relative ${isDragging ? 'opacity-30' : ''}`}
|
||||||
>
|
>
|
||||||
|
{/* 드래그 핸들 (좌측) */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
ref={setActivatorNodeRef}
|
ref={setActivatorNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-[var(--row-hover-bg)] transition touch-none"
|
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
|
||||||
aria-label="순서 변경"
|
aria-label="순서 변경"
|
||||||
/>
|
/>
|
||||||
<Link to={`bosses/${boss.id}`} className="block group hover:[&_h3]:text-[var(--accent-hover-text)] [&_h3]:transition">
|
{/* 카드 본체 - Link */}
|
||||||
|
<Link to={`bosses/${boss.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
|
||||||
<BossCardContent boss={boss} />
|
<BossCardContent boss={boss} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,20 +143,15 @@ export default function BossList() {
|
||||||
const activeBoss = items.find((b) => b.id === activeId)
|
const activeBoss = items.find((b) => b.id === activeId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pt-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">보스 결정 관리</h2>
|
<h2 className="text-lg font-semibold">보스 결정 관리</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>보스 정보 및 난이도별 결정 가격을 관리합니다</p>
|
<p className="text-sm text-gray-500 mt-0.5">보스 정보 및 난이도별 결정 가격을 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="bosses/new"
|
to="bosses/new"
|
||||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">+</span>
|
<span className="text-base leading-none">+</span>
|
||||||
보스 추가
|
보스 추가
|
||||||
|
|
@ -173,24 +161,14 @@ export default function BossList() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="h-32 rounded-2xl animate-pulse" style={{ background: 'var(--skeleton-bg)' }} />
|
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||||
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>
|
<div className="text-5xl mb-3 opacity-30">⚔️</div>
|
||||||
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>등록된 보스가 없습니다</p>
|
<p className="text-gray-400 mb-4">등록된 보스가 없습니다</p>
|
||||||
<Link
|
<Link to="bosses/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
|
||||||
to="bosses/new"
|
|
||||||
className="text-sm hover:text-[var(--accent-hover-text)]"
|
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
첫 보스 추가하기 →
|
첫 보스 추가하기 →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||||
import Tooltip from '../../../components/Tooltip'
|
import Tooltip from '../../../components/Tooltip'
|
||||||
import CharacterSuggestDropdown from '../../../components/CharacterSuggestDropdown'
|
|
||||||
import { useFitText } from '../../../hooks/useFitText'
|
import { useFitText } from '../../../hooks/useFitText'
|
||||||
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
||||||
|
|
||||||
|
|
@ -199,7 +198,6 @@ export default function CharacterPanel({
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const [confirmRemove, setConfirmRemove] = useState(null)
|
const [confirmRemove, setConfirmRemove] = useState(null)
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
|
||||||
|
|
||||||
const searchMutation = useMutation({
|
const searchMutation = useMutation({
|
||||||
mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
|
mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
|
||||||
|
|
@ -333,8 +331,6 @@ export default function CharacterPanel({
|
||||||
type="text"
|
type="text"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(e) => { setName(e.target.value); if (error) setError('') }}
|
onChange={(e) => { setName(e.target.value); if (error) setError('') }}
|
||||||
onFocus={() => setDropdownOpen(true)}
|
|
||||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
|
||||||
placeholder="캐릭터 닉네임 검색"
|
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)]"
|
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={{
|
style={{
|
||||||
|
|
@ -343,17 +339,6 @@ export default function CharacterPanel({
|
||||||
color: 'var(--text-strong)',
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useLayoutEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
|
|
@ -62,7 +62,7 @@ function calcMonthlyEarn(weekData) {
|
||||||
|
|
||||||
export default function Liberation() {
|
export default function Liberation() {
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
setFullscreen(true)
|
setFullscreen(true)
|
||||||
return () => setFullscreen(false)
|
return () => setFullscreen(false)
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState, useEffect, useLayoutEffect, useMemo } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
import { useQuery, useQueries, useMutation } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import utc from 'dayjs/plugin/utc'
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
|
@ -7,7 +7,6 @@ import { api } from '../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../components/Layout'
|
||||||
import Select from '../../components/Select'
|
import Select from '../../components/Select'
|
||||||
import Tooltip from '../../components/Tooltip'
|
import Tooltip from '../../components/Tooltip'
|
||||||
import CharacterSuggestDropdown from '../../components/CharacterSuggestDropdown'
|
|
||||||
import { useSymbolStore } from './store'
|
import { useSymbolStore } from './store'
|
||||||
|
|
||||||
dayjs.extend(utc)
|
dayjs.extend(utc)
|
||||||
|
|
@ -371,7 +370,7 @@ function SymbolCard({ symbol, equipped, charId }) {
|
||||||
|
|
||||||
export default function Symbol() {
|
export default function Symbol() {
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
setFullscreen(true)
|
setFullscreen(true)
|
||||||
return () => setFullscreen(false)
|
return () => setFullscreen(false)
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
@ -470,7 +469,6 @@ export default function Symbol() {
|
||||||
|
|
||||||
const [addName, setAddName] = useState('')
|
const [addName, setAddName] = useState('')
|
||||||
const [addError, setAddError] = useState('')
|
const [addError, setAddError] = useState('')
|
||||||
const [dropdownOpen, setDropdownOpen] = useState(false)
|
|
||||||
|
|
||||||
const symbols = allSymbols.filter((s) => s.type === tab)
|
const symbols = allSymbols.filter((s) => s.type === tab)
|
||||||
const tabInfo = tabs.find((t) => t.key === tab)
|
const tabInfo = tabs.find((t) => t.key === tab)
|
||||||
|
|
@ -572,8 +570,6 @@ export default function Symbol() {
|
||||||
type="text"
|
type="text"
|
||||||
value={addName}
|
value={addName}
|
||||||
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
|
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
|
||||||
onFocus={() => setDropdownOpen(true)}
|
|
||||||
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
|
||||||
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
|
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)]"
|
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={{
|
style={{
|
||||||
|
|
@ -582,17 +578,6 @@ export default function Symbol() {
|
||||||
color: 'var(--text-strong)',
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import Select from '../../../components/Select'
|
import Select from '../../../components/Select'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||||
import { useAuthStore } from '../../../stores/auth'
|
|
||||||
|
|
||||||
const TYPE_OPTIONS = [
|
const TYPE_OPTIONS = [
|
||||||
{ value: '아케인', label: '아케인' },
|
{ value: '아케인', label: '아케인' },
|
||||||
|
|
@ -12,12 +11,7 @@ const TYPE_OPTIONS = [
|
||||||
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
|
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
|
||||||
]
|
]
|
||||||
|
|
||||||
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
|
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
|
||||||
const inputStyle = {
|
|
||||||
background: 'var(--input-bg)',
|
|
||||||
borderColor: 'var(--input-border)',
|
|
||||||
color: 'var(--text-strong)',
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMesoKorean(n) {
|
function formatMesoKorean(n) {
|
||||||
if (!n || n <= 0) return ''
|
if (!n || n <= 0) return ''
|
||||||
|
|
@ -44,15 +38,9 @@ function MesoInput({ value, onChange, ...rest }) {
|
||||||
onChange(digits)
|
onChange(digits)
|
||||||
}}
|
}}
|
||||||
className={`${inputCls} tabular-nums text-right`}
|
className={`${inputCls} tabular-nums text-right`}
|
||||||
style={inputStyle}
|
|
||||||
{...rest}
|
{...rest}
|
||||||
/>
|
/>
|
||||||
<div
|
<div className="text-sm text-amber-300 mt-1 text-right tabular-nums min-h-[18px]">{korean || '\u00A0'}</div>
|
||||||
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
|
|
||||||
style={{ color: 'var(--warning-text-bright)' }}
|
|
||||||
>
|
|
||||||
{korean || '\u00A0'}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -61,13 +49,13 @@ function Field({ label, hint, error, required, children }) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<label className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
|
<label className="text-sm font-medium text-gray-300">
|
||||||
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
|
{label} {required && <span className="text-red-400">*</span>}
|
||||||
</label>
|
</label>
|
||||||
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
|
{hint && <span className="text-xs text-gray-500">{hint}</span>}
|
||||||
</div>
|
</div>
|
||||||
{children}
|
{children}
|
||||||
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
|
{error && <div className="text-[11px] text-red-400">{error}</div>}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -91,6 +79,7 @@ export default function SymbolForm() {
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
// 편집 시 데이터 로드
|
||||||
const { data: symbolData } = useQuery({
|
const { data: symbolData } = useQuery({
|
||||||
queryKey: ['admin', 'symbol', 'symbols', id],
|
queryKey: ['admin', 'symbol', 'symbols', id],
|
||||||
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
|
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
|
||||||
|
|
@ -159,7 +148,7 @@ export default function SymbolForm() {
|
||||||
))
|
))
|
||||||
if (imageFile) formData.append('image', imageFile)
|
if (imageFile) formData.append('image', imageFile)
|
||||||
|
|
||||||
const adminKey = useAuthStore.getState().apiKey
|
const adminKey = localStorage.getItem('maple-admin-key')
|
||||||
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
|
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: isEdit ? 'PATCH' : 'POST',
|
method: isEdit ? 'PATCH' : 'POST',
|
||||||
|
|
@ -199,51 +188,33 @@ export default function SymbolForm() {
|
||||||
|
|
||||||
const displayImage = imagePreview || existingImageUrl
|
const displayImage = imagePreview || existingImageUrl
|
||||||
|
|
||||||
const panelStyle = {
|
|
||||||
background: 'var(--panel-bg)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
boxShadow: 'var(--panel-shadow)',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6 pt-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
|
<h2 className="text-lg font-semibold">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
|
<p className="text-sm text-gray-500 mt-0.5">심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
|
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-5">
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
|
<div className="text-sm font-semibold text-emerald-300">기본 정보</div>
|
||||||
|
|
||||||
<Field label="심볼 이미지" required={!isEdit}>
|
<Field label="심볼 이미지" required={!isEdit}>
|
||||||
<label
|
<label className="flex items-center gap-4 rounded-xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 bg-gray-950/50 p-4 transition cursor-pointer">
|
||||||
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
|
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
style={{
|
|
||||||
background: 'var(--surface-3)',
|
|
||||||
borderColor: 'var(--dashed-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-32 h-32 rounded-lg border flex items-center justify-center overflow-hidden shrink-0"
|
|
||||||
style={{
|
|
||||||
background: 'var(--surface-nested)',
|
|
||||||
borderColor: 'var(--panel-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{displayImage ? (
|
{displayImage ? (
|
||||||
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
|
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-5xl" style={{ color: 'var(--text-dim)' }}>+</span>
|
<span className="text-5xl text-gray-700">+</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
|
<div className="text-sm font-medium text-gray-300">
|
||||||
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs mt-1" style={{ color: 'var(--text-dim)' }}>PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
||||||
{imageFile && (
|
{imageFile && (
|
||||||
<div className="text-xs mt-2 truncate" style={{ color: 'var(--accent-bright)' }}>📎 {imageFile.name}</div>
|
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
|
||||||
|
|
@ -261,7 +232,6 @@ export default function SymbolForm() {
|
||||||
value={region}
|
value={region}
|
||||||
onChange={(e) => setRegion(e.target.value)}
|
onChange={(e) => setRegion(e.target.value)}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
placeholder="소멸의 여로"
|
placeholder="소멸의 여로"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -274,7 +244,6 @@ export default function SymbolForm() {
|
||||||
value={maxLevel}
|
value={maxLevel}
|
||||||
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
min="2"
|
min="2"
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -284,7 +253,6 @@ export default function SymbolForm() {
|
||||||
value={dailyDefault}
|
value={dailyDefault}
|
||||||
onChange={(e) => setDailyDefault(e.target.value)}
|
onChange={(e) => setDailyDefault(e.target.value)}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="기본 주간퀘 획득량">
|
<Field label="기본 주간퀘 획득량">
|
||||||
|
|
@ -293,35 +261,35 @@ export default function SymbolForm() {
|
||||||
value={weeklyDefault}
|
value={weeklyDefault}
|
||||||
onChange={(e) => setWeeklyDefault(e.target.value)}
|
onChange={(e) => setWeeklyDefault(e.target.value)}
|
||||||
className={inputCls}
|
className={inputCls}
|
||||||
style={inputStyle}
|
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 레벨별 설정 */}
|
{/* 레벨별 설정 */}
|
||||||
<div className="rounded-2xl border p-6 space-y-4" style={panelStyle}>
|
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-4">
|
||||||
<div className="flex items-baseline justify-between">
|
<div className="flex items-baseline justify-between">
|
||||||
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>레벨별 필요 개수 · 메소</div>
|
<div className="text-sm font-semibold text-emerald-300">레벨별 필요 개수 · 메소</div>
|
||||||
<div className="text-xs" style={{ color: 'var(--text-dim)' }}>레벨 N → N+1 업그레이드 기준 (만렙-1행)</div>
|
<div className="text-xs text-gray-500">레벨 N → N+1 업그레이드 기준 (만렙-1행)</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-xs uppercase border-b" style={{ color: 'var(--text-dim)', borderColor: 'var(--panel-border)' }}>
|
<tr className="text-xs text-gray-500 uppercase border-b border-white/5">
|
||||||
<th className="py-2 px-3 text-left font-medium w-20">레벨</th>
|
<th className="py-2 px-3 text-left font-medium w-20">레벨</th>
|
||||||
<th className="py-2 px-3 text-left font-medium">필요 심볼 수</th>
|
<th className="py-2 px-3 text-left font-medium">필요 심볼 수</th>
|
||||||
<th className="py-2 px-3 text-left font-medium">메소</th>
|
<th className="py-2 px-3 text-left font-medium">메소</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-white/5">
|
||||||
{levels.map((l, idx) => (
|
{levels.map((l, idx) => (
|
||||||
<tr key={l.level} className="border-t first:border-t-0" style={{ borderColor: 'var(--row-divider)' }}>
|
<tr key={l.level}>
|
||||||
<td className="py-1.5 px-3 tabular-nums" style={{ color: 'var(--text-muted)' }}>
|
<td className="py-1.5 px-3 text-gray-400 tabular-nums">
|
||||||
Lv.<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{l.level}</span>
|
Lv.<span className="text-gray-200 font-semibold">{l.level}</span>
|
||||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>→</span>
|
<span className="text-gray-600 mx-1">→</span>
|
||||||
{l.level + 1}
|
{l.level + 1}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-1.5 px-3">
|
<td className="py-1.5 px-3">
|
||||||
|
|
@ -330,7 +298,6 @@ export default function SymbolForm() {
|
||||||
value={l.required_count}
|
value={l.required_count}
|
||||||
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
|
||||||
className={`${inputCls} max-w-36`}
|
className={`${inputCls} max-w-36`}
|
||||||
style={inputStyle}
|
|
||||||
placeholder="0"
|
placeholder="0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -357,12 +324,7 @@ export default function SymbolForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setConfirmDelete(true)}
|
onClick={() => setConfirmDelete(true)}
|
||||||
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-[var(--danger-bg-hover)]"
|
className="rounded-lg border border-red-500/40 bg-red-500/10 hover:bg-red-500/20 text-red-300 px-4 py-2 text-sm font-medium transition"
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
background: 'var(--icon-danger-bg)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -372,12 +334,7 @@ export default function SymbolForm() {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => navigate('..')}
|
onClick={() => navigate('..')}
|
||||||
className="rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
|
className="rounded-lg border border-white/10 hover:bg-white/5 text-gray-300 px-4 py-2 text-sm transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-bg)',
|
|
||||||
borderColor: 'var(--btn-border)',
|
|
||||||
color: 'var(--text-emphasis)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -385,12 +342,7 @@ export default function SymbolForm() {
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={saveMutation.isPending}
|
disabled={saveMutation.isPending}
|
||||||
className="rounded-lg px-5 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
|
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white px-5 py-2 text-sm font-semibold shadow-lg shadow-emerald-500/20 transition"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
|
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -398,14 +350,7 @@ export default function SymbolForm() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div className="rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-sm px-4 py-2">
|
||||||
className="rounded-lg border text-sm px-4 py-2"
|
|
||||||
style={{
|
|
||||||
borderColor: 'var(--icon-danger-border)',
|
|
||||||
background: 'var(--icon-danger-bg)',
|
|
||||||
color: 'var(--danger-text)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -12,36 +12,21 @@ import {
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../api/client'
|
||||||
|
|
||||||
const TYPE_STYLE = {
|
const TYPE_COLOR = {
|
||||||
'아케인': {
|
'아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' },
|
||||||
color: 'var(--symbol-arcane-text)',
|
'어센틱': { text: 'text-sky-300', bg: 'bg-sky-500/15', border: 'border-sky-500/30' },
|
||||||
background: 'var(--symbol-arcane-bg)',
|
'그랜드 어센틱': { text: 'text-amber-300', bg: 'bg-amber-500/15', border: 'border-amber-500/30' },
|
||||||
borderColor: 'var(--symbol-arcane-border)',
|
|
||||||
},
|
|
||||||
'어센틱': {
|
|
||||||
color: 'var(--symbol-authentic-text)',
|
|
||||||
background: 'var(--symbol-authentic-bg)',
|
|
||||||
borderColor: 'var(--symbol-authentic-border)',
|
|
||||||
},
|
|
||||||
'그랜드 어센틱': {
|
|
||||||
color: 'var(--symbol-grand-text)',
|
|
||||||
background: 'var(--symbol-grand-bg)',
|
|
||||||
borderColor: 'var(--symbol-grand-border)',
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SymbolCardContent({ symbol, dragging = false }) {
|
function SymbolCardContent({ symbol, dragging = false }) {
|
||||||
const badgeStyle = TYPE_STYLE[symbol.type] || TYPE_STYLE['아케인']
|
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
||||||
className="flex items-stretch rounded-2xl border"
|
dragging
|
||||||
style={{
|
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
|
: 'border-white/5'
|
||||||
borderColor: dragging ? 'var(--selected-border)' : 'var(--card-border)',
|
}`}>
|
||||||
boxShadow: dragging ? '0 12px 32px rgba(16, 185, 129, 0.25)' : 'var(--card-shadow)',
|
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center px-2 cursor-grab active:cursor-grabbing" style={{ color: 'var(--text-dim)' }}>
|
|
||||||
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
|
||||||
<circle cx="4" cy="4" r="1.5" /><circle cx="10" cy="4" r="1.5" />
|
<circle cx="4" cy="4" r="1.5" /><circle cx="10" cy="4" r="1.5" />
|
||||||
<circle cx="4" cy="10" r="1.5" /><circle cx="10" cy="10" r="1.5" />
|
<circle cx="4" cy="10" r="1.5" /><circle cx="10" cy="10" r="1.5" />
|
||||||
|
|
@ -49,30 +34,21 @@ function SymbolCardContent({ symbol, dragging = false }) {
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
|
||||||
<div
|
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||||
className="shrink-0 w-14 h-14 rounded-xl border flex items-center justify-center overflow-hidden"
|
|
||||||
style={{
|
|
||||||
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
|
|
||||||
borderColor: 'var(--icon-box-border)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{symbol.image_url ? (
|
{symbol.image_url ? (
|
||||||
<img src={symbol.image_url} alt="" className="w-12 h-12 object-contain" style={{ imageRendering: 'pixelated' }} />
|
<img src={symbol.image_url} alt="" className="w-12 h-12 object-contain" style={{ imageRendering: 'pixelated' }} />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-2xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
<span className="text-gray-700 text-2xl">?</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-baseline gap-2 flex-wrap">
|
<div className="flex items-baseline gap-2 flex-wrap">
|
||||||
<h3 className="font-medium truncate">{symbol.region}</h3>
|
<h3 className="font-semibold truncate">{symbol.region}</h3>
|
||||||
<span
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${color.text} ${color.bg} ${color.border}`}>
|
||||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded border"
|
|
||||||
style={badgeStyle}
|
|
||||||
>
|
|
||||||
{symbol.type}
|
{symbol.type}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs tabular-nums" style={{ color: 'var(--text-dim)' }}>
|
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 tabular-nums">
|
||||||
<span>만렙 {symbol.max_level}</span>
|
<span>만렙 {symbol.max_level}</span>
|
||||||
<span>일퀘 {symbol.daily_default}</span>
|
<span>일퀘 {symbol.daily_default}</span>
|
||||||
<span>주간퀘 {symbol.weekly_default}</span>
|
<span>주간퀘 {symbol.weekly_default}</span>
|
||||||
|
|
@ -96,10 +72,10 @@ function SortableSymbolCard({ symbol }) {
|
||||||
ref={setActivatorNodeRef}
|
ref={setActivatorNodeRef}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-[var(--row-hover-bg)] transition touch-none"
|
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
|
||||||
aria-label="순서 변경"
|
aria-label="순서 변경"
|
||||||
/>
|
/>
|
||||||
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-[var(--accent-hover-text)] [&_h3]:transition">
|
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
|
||||||
<SymbolCardContent symbol={symbol} />
|
<SymbolCardContent symbol={symbol} />
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,20 +126,15 @@ export default function SymbolList() {
|
||||||
const activeSymbol = items.find((s) => s.id === activeId)
|
const activeSymbol = items.find((s) => s.id === activeId)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-5xl mx-auto pt-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-end justify-between gap-4 flex-wrap">
|
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-medium">심볼 관리</h2>
|
<h2 className="text-lg font-semibold">심볼 관리</h2>
|
||||||
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>심볼 정보 및 레벨별 필요 개수/메소를 관리합니다</p>
|
<p className="text-sm text-gray-500 mt-0.5">심볼 정보 및 레벨별 필요 개수/메소를 관리합니다</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="symbols/new"
|
to="symbols/new"
|
||||||
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
|
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"
|
||||||
style={{
|
|
||||||
background: 'var(--btn-primary-bg)',
|
|
||||||
color: 'var(--btn-primary-text)',
|
|
||||||
boxShadow: 'var(--btn-primary-shadow)',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<span className="text-base leading-none">+</span>
|
<span className="text-base leading-none">+</span>
|
||||||
심볼 추가
|
심볼 추가
|
||||||
|
|
@ -173,24 +144,14 @@ export default function SymbolList() {
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
<div key={i} className="h-24 rounded-2xl animate-pulse" style={{ background: 'var(--skeleton-bg)' }} />
|
<div key={i} className="h-24 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : items.length === 0 ? (
|
) : items.length === 0 ? (
|
||||||
<div
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||||
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>
|
<div className="text-5xl mb-3 opacity-30">🔮</div>
|
||||||
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>등록된 심볼이 없습니다</p>
|
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
|
||||||
<Link
|
<Link to="symbols/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
|
||||||
to="symbols/new"
|
|
||||||
className="text-sm hover:text-[var(--accent-hover-text)]"
|
|
||||||
style={{ color: 'var(--accent)' }}
|
|
||||||
>
|
|
||||||
첫 심볼 추가하기 →
|
첫 심볼 추가하기 →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -145,16 +145,6 @@
|
||||||
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
|
--liberation-primary-bar: rgba(167, 139, 250, 0.5);
|
||||||
--liberation-secondary: #fda4af;
|
--liberation-secondary: #fda4af;
|
||||||
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
|
--liberation-secondary-bar: rgba(253, 164, 175, 0.5);
|
||||||
|
|
||||||
--symbol-arcane-text: #c4b5fd;
|
|
||||||
--symbol-arcane-bg: rgba(139, 92, 246, 0.15);
|
|
||||||
--symbol-arcane-border: rgba(139, 92, 246, 0.3);
|
|
||||||
--symbol-authentic-text: #7dd3fc;
|
|
||||||
--symbol-authentic-bg: rgba(14, 165, 233, 0.15);
|
|
||||||
--symbol-authentic-border: rgba(14, 165, 233, 0.3);
|
|
||||||
--symbol-grand-text: #fcd34d;
|
|
||||||
--symbol-grand-bg: rgba(245, 158, 11, 0.15);
|
|
||||||
--symbol-grand-border: rgba(245, 158, 11, 0.3);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 테마 토큰 - light */
|
/* 테마 토큰 - light */
|
||||||
|
|
@ -296,16 +286,6 @@
|
||||||
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
|
--liberation-primary-bar: rgba(124, 58, 237, 0.5);
|
||||||
--liberation-secondary: #e11d48;
|
--liberation-secondary: #e11d48;
|
||||||
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
|
--liberation-secondary-bar: rgba(225, 29, 72, 0.5);
|
||||||
|
|
||||||
--symbol-arcane-text: #6d28d9;
|
|
||||||
--symbol-arcane-bg: rgba(139, 92, 246, 0.12);
|
|
||||||
--symbol-arcane-border: rgba(109, 40, 217, 0.4);
|
|
||||||
--symbol-authentic-text: #0369a1;
|
|
||||||
--symbol-authentic-bg: rgba(14, 165, 233, 0.12);
|
|
||||||
--symbol-authentic-border: rgba(3, 105, 161, 0.4);
|
|
||||||
--symbol-grand-text: #b45309;
|
|
||||||
--symbol-grand-bg: rgba(245, 158, 11, 0.15);
|
|
||||||
--symbol-grand-border: rgba(180, 83, 9, 0.4);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
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