심볼 계산기/관리자 API 연동 및 입력 확장

- 공개 /api/symbols 엔드포인트 추가 (레벨 포함)
- 심볼 계산기가 DB 데이터 기반으로 탭·카드 구성, 하드코딩 data.js 제거
- 심볼 카드 입력: 일퀘/주간퀘 Select(회→개 표기)/추가 심볼 3열
- 카드 상단에 '금일 일퀘 완료/미완료' 토글 (완료=에메랄드, 미완료=빨간색)
- 관리자 페이지: 목록/폼 실 API 연결, dnd-kit 드래그 순서 변경,
  레벨별 메소 입력 쉼표 포매팅 및 한글 요약 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-15 13:43:52 +09:00
parent 33de50bc2d
commit eb4369d8fb
9 changed files with 520 additions and 90 deletions

View file

@ -5,6 +5,7 @@ import { convertAndUpload, deleteFromS3 } from '../services/image.js';
import { getPublicUrl } from '../lib/s3.js';
import { sequelize } from '../lib/db.js';
import bossCrystalRouter from './admin/boss-crystal.js';
import symbolRouter from './admin/symbol.js';
const router = Router();
const upload = multer({
@ -35,6 +36,7 @@ router.use(requireAdmin);
// 기능별 sub-router
router.use('/boss-crystal', bossCrystalRouter);
router.use('/symbol', symbolRouter);
/* ── 이미지 관리 ── */

View file

@ -0,0 +1,237 @@
import { Router } from 'express';
import multer from 'multer';
import { Symbol, SymbolLevel } from '../../models/index.js';
import { convertAndUploadTo, deleteFromS3 } from '../../services/image.js';
import { getPublicUrl } from '../../lib/s3.js';
import { sequelize } from '../../lib/db.js';
const router = Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
const VALID_TYPES = ['아케인', '어센틱', '그랜드 어센틱'];
function imagePath(type, region) {
return `symbol/${type}심볼(${region}).webp`;
}
function serialize(symbol) {
const json = symbol.toJSON();
return {
id: json.id,
type: json.type,
region: json.region,
image: json.image,
image_url: json.image ? getPublicUrl(json.image) : null,
max_level: json.max_level,
daily_default: json.daily_default,
weekly_default: json.weekly_default,
sort_order: json.sort_order,
levels: (json.levels || [])
.sort((a, b) => a.level - b.level)
.map((l) => ({
level: l.level,
required_count: l.required_count,
meso_cost: Number(l.meso_cost),
})),
};
}
function parseLevels(raw, maxLevel) {
if (!raw) return [];
let arr;
try {
arr = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
throw new Error('레벨 정보 형식이 잘못되었습니다');
}
if (!Array.isArray(arr)) throw new Error('레벨 정보는 배열이어야 합니다');
return arr.map((l) => {
const level = Number(l.level);
const required_count = Number(l.required_count);
const meso_cost = Number(l.meso_cost);
if (!level || level < 1 || level >= maxLevel) {
throw new Error(`잘못된 레벨: ${l.level}`);
}
if (isNaN(required_count) || required_count < 0) {
throw new Error(`잘못된 필요 개수: Lv.${level}`);
}
if (isNaN(meso_cost) || meso_cost < 0) {
throw new Error(`잘못된 메소: Lv.${level}`);
}
return { level, required_count, meso_cost };
});
}
function validateBasic({ type, region, max_level }) {
if (!VALID_TYPES.includes(type)) throw new Error('잘못된 심볼 종류입니다');
const r = String(region || '').trim();
if (!r) throw new Error('지역 이름을 입력해주세요');
const ml = Number(max_level);
if (!ml || ml < 2 || ml > 99) throw new Error('만렙은 2~99 사이여야 합니다');
return { type, region: r, max_level: ml };
}
// 목록
router.get('/symbols', async (_req, res) => {
try {
const rows = await Symbol.findAll({
order: [['sort_order', 'ASC'], ['id', 'ASC']],
include: [{ model: SymbolLevel, as: 'levels' }],
});
res.json(rows.map(serialize));
} catch (err) {
console.error('심볼 목록 조회 오류:', err.message);
res.status(500).json({ error: '심볼 목록 조회 실패' });
}
});
// 단건
router.get('/symbols/:id', async (req, res) => {
try {
const row = await Symbol.findByPk(req.params.id, {
include: [{ model: SymbolLevel, as: 'levels' }],
});
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
res.json(serialize(row));
} catch (err) {
console.error('심볼 조회 오류:', err.message);
res.status(500).json({ error: '심볼 조회 실패' });
}
});
// 생성
router.post('/symbols', upload.single('image'), async (req, res) => {
const t = await sequelize.transaction();
try {
const basic = validateBasic(req.body);
const levels = parseLevels(req.body.levels, basic.max_level);
const daily_default = Number(req.body.daily_default) || 0;
const weekly_default = Number(req.body.weekly_default) || 0;
if (!req.file) throw new Error('심볼 이미지를 업로드해주세요');
const key = imagePath(basic.type, basic.region);
await convertAndUploadTo(req.file.buffer, key);
const maxOrder = (await Symbol.max('sort_order')) || 0;
const created = await Symbol.create({
type: basic.type,
region: basic.region,
image: key,
max_level: basic.max_level,
daily_default,
weekly_default,
sort_order: maxOrder + 1,
}, { transaction: t });
if (levels.length) {
await SymbolLevel.bulkCreate(
levels.map((l) => ({ symbol_id: created.id, ...l })),
{ transaction: t }
);
}
await t.commit();
const full = await Symbol.findByPk(created.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
res.status(201).json(serialize(full));
} catch (err) {
await t.rollback();
console.error('심볼 생성 오류:', err.message);
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(409).json({ error: '이미 등록된 종류+지역 조합입니다' });
}
res.status(400).json({ error: err.message || '심볼 생성 실패' });
}
});
// 수정
router.patch('/symbols/:id', upload.single('image'), async (req, res) => {
const t = await sequelize.transaction();
try {
const row = await Symbol.findByPk(req.params.id);
if (!row) { await t.rollback(); return res.status(404).json({ error: '심볼을 찾을 수 없습니다' }); }
const basic = validateBasic({
type: req.body.type ?? row.type,
region: req.body.region ?? row.region,
max_level: req.body.max_level ?? row.max_level,
});
let imageKey = row.image;
if (req.file) {
imageKey = imagePath(basic.type, basic.region);
await convertAndUploadTo(req.file.buffer, imageKey);
if (row.image && row.image !== imageKey) {
try { await deleteFromS3(row.image); } catch { /* ignore */ }
}
} else if (basic.type !== row.type || basic.region !== row.region) {
// 이름/종류 변경 시 새 경로로 rename 대체 불가 → 기존 키 유지
imageKey = row.image;
}
await row.update({
type: basic.type,
region: basic.region,
max_level: basic.max_level,
image: imageKey,
daily_default: Number(req.body.daily_default) || 0,
weekly_default: Number(req.body.weekly_default) || 0,
}, { transaction: t });
if (req.body.levels !== undefined) {
const levels = parseLevels(req.body.levels, basic.max_level);
await SymbolLevel.destroy({ where: { symbol_id: row.id }, transaction: t });
if (levels.length) {
await SymbolLevel.bulkCreate(
levels.map((l) => ({ symbol_id: row.id, ...l })),
{ transaction: t }
);
}
}
await t.commit();
const full = await Symbol.findByPk(row.id, { include: [{ model: SymbolLevel, as: 'levels' }] });
res.json(serialize(full));
} catch (err) {
await t.rollback();
console.error('심볼 수정 오류:', err.message);
res.status(400).json({ error: err.message || '심볼 수정 실패' });
}
});
// 삭제
router.delete('/symbols/:id', async (req, res) => {
try {
const row = await Symbol.findByPk(req.params.id);
if (!row) return res.status(404).json({ error: '심볼을 찾을 수 없습니다' });
const key = row.image;
await row.destroy();
if (key) { try { await deleteFromS3(key); } catch { /* ignore */ } }
res.json({ success: true });
} catch (err) {
console.error('심볼 삭제 오류:', err.message);
res.status(500).json({ error: '심볼 삭제 실패' });
}
});
// 순서 변경
router.post('/symbols/reorder', async (req, res) => {
const { ids } = req.body;
if (!Array.isArray(ids)) return res.status(400).json({ error: 'ids 배열이 필요합니다' });
const t = await sequelize.transaction();
try {
for (let i = 0; i < ids.length; i++) {
await Symbol.update({ sort_order: i + 1 }, { where: { id: ids[i] }, transaction: t });
}
await t.commit();
res.json({ success: true });
} catch (err) {
await t.rollback();
console.error('심볼 순서 변경 오류:', err.message);
res.status(500).json({ error: '순서 변경 실패' });
}
});
export default router;

39
backend/routes/symbol.js Normal file
View file

@ -0,0 +1,39 @@
import { Router } from 'express';
import { Symbol, SymbolLevel } from '../models/index.js';
import { getPublicUrl } from '../lib/s3.js';
const router = Router();
router.get('/', async (_req, res) => {
try {
const rows = await Symbol.findAll({
order: [['sort_order', 'ASC'], ['id', 'ASC']],
include: [{ model: SymbolLevel, as: 'levels' }],
});
res.json(rows.map((s) => {
const j = s.toJSON();
return {
id: j.id,
type: j.type,
region: j.region,
image_url: j.image ? getPublicUrl(j.image) : null,
max_level: j.max_level,
daily_default: j.daily_default,
weekly_default: j.weekly_default,
sort_order: j.sort_order,
levels: (j.levels || [])
.sort((a, b) => a.level - b.level)
.map((l) => ({
level: l.level,
required_count: l.required_count,
meso_cost: Number(l.meso_cost),
})),
};
}));
} catch (err) {
console.error('심볼 목록 조회 오류:', err.message);
res.status(500).json({ error: '심볼 목록 조회 실패' });
}
});
export default router;

View file

@ -6,6 +6,7 @@ import noticeRoutes from './routes/notices.js';
import bossCrystalRoutes from './routes/boss-crystal.js';
import characterRoutes from './routes/character.js';
import imageRoutes from './routes/images.js';
import symbolRoutes from './routes/symbol.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
@ -25,6 +26,7 @@ app.use('/api/notices', noticeRoutes);
app.use('/api/boss-crystal', bossCrystalRoutes);
app.use('/api/character', characterRoutes);
app.use('/api/images', imageRoutes);
app.use('/api/symbols', symbolRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {

View file

@ -29,3 +29,14 @@ export async function convertAndUpload(buffer) {
export async function deleteFromS3(path) {
await deleteObject(path);
}
/**
* 지정한 경로로 webp 변환 업로드 (덮어쓰기)
* @param {Buffer} buffer - 원본 이미지 버퍼
* @param {string} path - S3 (확장자 포함). : 'symbol/아케인심볼(소멸의 여로).webp'
*/
export async function convertAndUploadTo(buffer, path) {
const webpBuffer = await sharp(buffer).webp({ quality: 90 }).toBuffer();
await uploadObject(path, webpBuffer, 'image/webp');
return { path, size: webpBuffer.length };
}

View file

@ -1,8 +1,11 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo } from 'react'
import { useQuery, useMutation } from '@tanstack/react-query'
import { api } from '../../api/client'
import { useLayout } from '../../components/Layout'
import { SYMBOL_TABS, SYMBOLS } from './data'
import Select from '../../components/Select'
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
function CharacterCard({ char, active, onSelect, onRemove }) {
return (
@ -54,26 +57,17 @@ function CharacterCard({ char, active, onSelect, onRemove }) {
)
}
function TabImage({ name }) {
const { data } = useQuery({
queryKey: ['image', name],
queryFn: () => api('/api/images/' + encodeURIComponent(name)).catch(() => null),
staleTime: Infinity,
})
if (!data?.url) return <div className="w-8 h-8 bg-gray-800 rounded" />
return <img src={data.url} alt="" className="w-8 h-8 object-contain" />
}
function SymbolCard({ symbol, equipped }) {
//
const level = equipped ? 7 : 0
const maxLevel = 20
const growth = equipped ? 120 : 0
const requireGrowth = 60
const remainingSymbols = 540
const remainingMeso = 128_000_000
const daysLeft = equipped ? 84 : '-'
const completeDate = equipped ? '2026년 07월 09일 (목)' : '미장착'
const [weeklyCount, setWeeklyCount] = useState(3)
const [dailyDone, setDailyDone] = useState(false)
// ( )
const level = equipped ? 0 : 0
const growth = 0
const requireGrowth = symbol.levels?.[0]?.required_count || 0
const remainingSymbols = '-'
const remainingMeso = '-'
const daysLeft = '-'
const completeDate = '-'
return (
<div className={`rounded-2xl border p-5 transition ${
@ -83,20 +77,35 @@ function SymbolCard({ symbol, equipped }) {
}`}>
<div className="flex items-center gap-3 mb-4">
<div className="w-14 h-14 rounded-lg bg-gray-950 overflow-hidden shrink-0 flex items-center justify-center">
{symbol.image_url && (
<img
src={symbol.image}
alt={symbol.name}
src={symbol.image_url}
alt={symbol.region}
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
style={{ imageRendering: 'pixelated' }}
/>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-base font-semibold text-gray-100 truncate">{symbol.name}</div>
<div className="text-base font-semibold text-gray-100 truncate">{symbol.region}</div>
<div className="text-sm text-gray-400 tabular-nums mt-0.5">
Lv.<span className="text-emerald-300 font-bold text-base">{level}</span>
<span className="text-gray-600"> / {maxLevel}</span>
<span className="text-gray-600"> / {symbol.max_level}</span>
</div>
</div>
<button
type="button"
disabled={!equipped}
onClick={() => setDailyDone((v) => !v)}
title="오늘 일퀘 완료 여부"
className={`shrink-0 rounded-md h-8 px-3 text-xs font-semibold border transition disabled:opacity-40 disabled:cursor-not-allowed ${
dailyDone
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
: 'bg-red-500/10 border-red-500/40 text-red-300 hover:bg-red-500/20'
}`}
>
{dailyDone ? '금일 일퀘 완료' : '금일 일퀘 미완료'}
</button>
</div>
{/* 진행도 바 */}
@ -114,23 +123,35 @@ function SymbolCard({ symbol, equipped }) {
</div>
{/* 획득량 입력 */}
<div className="grid grid-cols-2 gap-2 mb-4">
<div className="grid gap-2 mb-4" style={{ gridTemplateColumns: '0.7fr 1.3fr 1fr' }}>
<div className="space-y-1">
<label className="block text-xs text-gray-400">일퀘 획득</label>
<input
type="text"
inputMode="numeric"
defaultValue={equipped ? '20' : '0'}
defaultValue={equipped ? String(symbol.daily_default) : '0'}
disabled={!equipped}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-gray-400">주간퀘 획득</label>
<Select
value={weeklyCount}
onChange={setWeeklyCount}
options={[1, 2, 3].map((n) => ({
value: n,
label: `${n * (symbol.weekly_default || 0)}`,
}))}
disabled={!equipped}
/>
</div>
<div className="space-y-1">
<label className="block text-xs text-gray-400">추가 심볼</label>
<input
type="text"
inputMode="numeric"
defaultValue={equipped ? '45' : '0'}
defaultValue="0"
disabled={!equipped}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/>
@ -141,15 +162,15 @@ function SymbolCard({ symbol, equipped }) {
<div className="divide-y divide-white/5 text-base">
<div className="flex justify-between py-2">
<span className="text-gray-400">남은 심볼</span>
<span className="tabular-nums text-gray-200 font-medium">{remainingSymbols.toLocaleString()}</span>
<span className="tabular-nums text-gray-200 font-medium">{remainingSymbols}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">필요 메소</span>
<span className="tabular-nums text-amber-300 font-medium">{remainingMeso.toLocaleString()}</span>
<span className="tabular-nums text-amber-300 font-medium">{remainingMeso}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">체납 메소</span>
<span className="tabular-nums text-red-400 font-medium">{(equipped ? 18_000_000 : 0).toLocaleString()}</span>
<span className="tabular-nums text-red-400 font-medium">-</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">남은 일수</span>
@ -174,7 +195,28 @@ export default function Symbol() {
}, [setFullscreen])
const STORAGE_KEY = 'maple-symbol'
const [tab, setTab] = useState('arcane')
// (DB )
const { data: allSymbols = [] } = useQuery({
queryKey: ['symbol', 'symbols'],
queryFn: () => api('/api/symbols').catch(() => []),
staleTime: 5 * 60 * 1000,
})
const tabs = useMemo(() => {
const groups = {}
for (const s of allSymbols) {
if (!groups[s.type]) groups[s.type] = s
}
return TYPE_ORDER
.filter((t) => groups[t])
.map((t) => ({ key: t, label: `${t} 심볼`, image_url: groups[t].image_url }))
}, [allSymbols])
const [tab, setTab] = useState(null)
useEffect(() => {
if (!tab && tabs.length) setTab(tabs[0].key)
}, [tabs, tab])
const [characters, setCharacters] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
@ -195,8 +237,8 @@ export default function Symbol() {
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId }))
}, [characters, selectedCharId])
const symbols = SYMBOLS[tab]
const tabInfo = SYMBOL_TABS.find((t) => t.key === tab)
const symbols = allSymbols.filter((s) => s.type === tab)
const tabInfo = tabs.find((t) => t.key === tab)
const searchMutation = useMutation({
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
@ -277,7 +319,7 @@ export default function Symbol() {
{/* 심볼 타입 탭 */}
<div className="flex gap-2">
{SYMBOL_TABS.map((t) => (
{tabs.map((t) => (
<button
key={t.key}
type="button"
@ -288,7 +330,11 @@ export default function Symbol() {
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
}`}
>
<TabImage name={t.imageName} />
{t.image_url ? (
<img src={t.image_url} alt="" className="w-8 h-8 object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<div className="w-8 h-8 bg-gray-800 rounded" />
)}
<span className="text-base font-semibold">{t.label}</span>
</button>
))}
@ -297,7 +343,7 @@ export default function Symbol() {
{/* 심볼 카드 그리드 */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{symbols.map((s, i) => (
<SymbolCard key={s.key} symbol={s} equipped={isEquipped(i)} />
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(i)} />
))}
</div>

View file

@ -1,5 +1,7 @@
import { useState, useRef } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import Select from '../../../components/Select'
import ConfirmDialog from '../../../components/ConfirmDialog'
@ -60,6 +62,7 @@ function Field({ label, hint, error, required, children }) {
export default function SymbolForm() {
const navigate = useNavigate()
const queryClient = useQueryClient()
const { id } = useParams()
const isEdit = !!id
const fileInputRef = useRef(null)
@ -71,8 +74,37 @@ export default function SymbolForm() {
const [weeklyDefault, setWeeklyDefault] = useState('')
const [imageFile, setImageFile] = useState(null)
const [imagePreview, setImagePreview] = useState(null)
const [existingImageUrl, setExistingImageUrl] = useState(null)
const [levels, setLevels] = useState([])
const [confirmDelete, setConfirmDelete] = useState(false)
const [error, setError] = useState('')
//
const { data: symbolData } = useQuery({
queryKey: ['admin', 'symbol', 'symbols', id],
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
enabled: isEdit,
})
useEffect(() => {
if (!symbolData) return
setType(symbolData.type)
setRegion(symbolData.region)
setMaxLevel(String(symbolData.max_level))
setDailyDefault(String(symbolData.daily_default ?? ''))
setWeeklyDefault(String(symbolData.weekly_default ?? ''))
setExistingImageUrl(symbolData.image_url)
const rows = Array.from({ length: symbolData.max_level - 1 }, (_, i) => {
const level = i + 1
const existing = symbolData.levels.find((l) => l.level === level)
return {
level,
required_count: existing?.required_count ?? '',
meso_cost: existing?.meso_cost ?? '',
}
})
setLevels(rows)
}, [symbolData])
const handleFile = (e) => {
const file = e.target.files?.[0]
@ -97,6 +129,65 @@ export default function SymbolForm() {
})
}
const saveMutation = useMutation({
mutationFn: async () => {
const formData = new FormData()
formData.append('type', type)
formData.append('region', region.trim())
formData.append('max_level', String(maxLevel))
formData.append('daily_default', String(Number(dailyDefault) || 0))
formData.append('weekly_default', String(Number(weeklyDefault) || 0))
formData.append('levels', JSON.stringify(
levels
.filter((l) => l.required_count !== '' || l.meso_cost !== '')
.map((l) => ({
level: l.level,
required_count: Number(l.required_count) || 0,
meso_cost: Number(l.meso_cost) || 0,
}))
))
if (imageFile) formData.append('image', imageFile)
const adminKey = localStorage.getItem('maple-admin-key')
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
headers: { 'x-admin-key': adminKey },
body: formData,
})
const json = await res.json()
if (!res.ok) throw new Error(json.error || '저장 실패')
return json
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
navigate('..')
},
onError: (err) => setError(err.message),
})
const deleteMutation = useMutation({
mutationFn: () => api(`/api/admin/symbol/symbols/${id}`, { method: 'DELETE' }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
navigate('..')
},
onError: (err) => alert(err.message),
})
const handleSubmit = () => {
setError('')
if (!type) return setError('심볼 종류를 선택해주세요')
if (!region.trim()) return setError('지역 이름을 입력해주세요')
if (!maxLevel || Number(maxLevel) < 2) return setError('만렙을 입력해주세요')
if (!isEdit && !imageFile) return setError('심볼 이미지를 업로드해주세요')
saveMutation.mutate()
}
const displayImage = imagePreview || existingImageUrl
return (
<div className="max-w-2xl mx-auto space-y-6">
<div>
@ -111,15 +202,15 @@ export default function SymbolForm() {
<Field label="심볼 이미지" required={!isEdit}>
<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">
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
{imagePreview ? (
<img src={imagePreview} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
{displayImage ? (
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<span className="text-5xl text-gray-700">+</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-300">
{imagePreview ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
</div>
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF WebP로 자동 변환됩니다</p>
{imageFile && (
@ -249,19 +340,27 @@ export default function SymbolForm() {
</button>
<button
type="button"
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 text-white px-5 py-2 text-sm font-semibold shadow-lg shadow-emerald-500/20 transition"
onClick={handleSubmit}
disabled={saveMutation.isPending}
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"
>
{isEdit ? '저장' : '추가'}
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
</button>
</div>
</div>
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-sm px-4 py-2">
{error}
</div>
)}
<ConfirmDialog
open={confirmDelete}
onClose={() => setConfirmDelete(false)}
onConfirm={() => setConfirmDelete(false)}
onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }}
title="심볼 삭제"
description="이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다."
description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'}
confirmText="삭제"
destructive
/>

View file

@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
useSensor, useSensors,
@ -9,13 +10,7 @@ import {
arrayMove,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
const MOCK_SYMBOLS = [
{ id: 1, type: '아케인', region: '소멸의 여로', image_url: 'https://s3.caadiq.co.kr/maplestory/symbol/아케인심볼(소멸의 여로).webp', max_level: 20, daily_default: 20, weekly_default: 45 },
{ id: 2, type: '아케인', region: '츄츄 아일랜드', image_url: 'https://s3.caadiq.co.kr/maplestory/symbol/아케인심볼(츄츄 아일랜드).webp', max_level: 20, daily_default: 20, weekly_default: 45 },
{ id: 3, type: '어센틱', region: '세르니움', image_url: 'https://s3.caadiq.co.kr/maplestory/symbol/어센틱심볼(세르니움).webp', max_level: 11, daily_default: 10, weekly_default: 25 },
{ id: 4, type: '그랜드 어센틱', region: '탈라하트', image_url: 'https://s3.caadiq.co.kr/maplestory/symbol/그랜드 어센틱심볼(탈라하트).webp', max_level: 11, daily_default: 0, weekly_default: 30 },
]
import { api } from '../../../api/client'
const TYPE_COLOR = {
'아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' },
@ -24,7 +19,7 @@ const TYPE_COLOR = {
}
function SymbolCardContent({ symbol, dragging = false }) {
const color = TYPE_COLOR[symbol.type]
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
return (
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
dragging
@ -88,21 +83,44 @@ function SortableSymbolCard({ symbol }) {
}
export default function SymbolList() {
const [items, setItems] = useState(MOCK_SYMBOLS)
const queryClient = useQueryClient()
const { data: symbols = [], isLoading } = useQuery({
queryKey: ['admin', 'symbol', 'symbols'],
queryFn: () => api('/api/admin/symbol/symbols').catch(() => []),
})
const [items, setItems] = useState([])
const [activeId, setActiveId] = useState(null)
useEffect(() => { setItems(symbols) }, [symbols])
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const reorderMutation = useMutation({
mutationFn: (ids) => api('/api/admin/symbol/symbols/reorder', {
method: 'POST',
body: { ids },
}),
onError: (err) => {
alert(err.message)
queryClient.invalidateQueries({ queryKey: ['admin', 'symbol', 'symbols'] })
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['symbol', 'symbols'] })
},
})
const handleDragEnd = (event) => {
const { active, over } = event
setActiveId(null)
if (!over || active.id === over.id) return
const oldIdx = items.findIndex((s) => s.id === active.id)
const newIdx = items.findIndex((s) => s.id === over.id)
setItems(arrayMove(items, oldIdx, newIdx))
const next = arrayMove(items, oldIdx, newIdx)
setItems(next)
reorderMutation.mutate(next.map((s) => s.id))
}
const activeSymbol = items.find((s) => s.id === activeId)
@ -123,7 +141,13 @@ export default function SymbolList() {
</Link>
</div>
{items.length === 0 ? (
{isLoading ? (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="h-24 rounded-2xl bg-white/[0.02] animate-pulse" />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div className="text-5xl mb-3 opacity-30">🔮</div>
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>

View file

@ -1,30 +0,0 @@
export const SYMBOL_TABS = [
{ key: 'arcane', label: '아케인 심볼', imageName: '아케인심볼 : 소멸의 여로', maxLevel: 20 },
{ key: 'authentic', label: '어센틱 심볼', imageName: '어센틱심볼 : 세르니움', maxLevel: 11 },
{ key: 'grand', label: '그랜드 어센틱 심볼', imageName: '그랜드 어센틱심볼 : 탈라하트', maxLevel: 11 },
]
const BASE = 'https://s3.caadiq.co.kr/maplestory/symbol'
export const SYMBOLS = {
arcane: [
{ key: 'yeoro', name: '소멸의 여로', image: `${BASE}/아케인심볼(소멸의 여로).webp` },
{ key: 'chuchu', name: '츄츄 아일랜드', image: `${BASE}/아케인심볼(츄츄 아일랜드).webp` },
{ key: 'lachelein', name: '레헬른', image: `${BASE}/아케인심볼(레헬른).webp` },
{ key: 'arcana', name: '아르카나', image: `${BASE}/아케인심볼(아르카나).webp` },
{ key: 'morass', name: '모라스', image: `${BASE}/아케인심볼(모라스).webp` },
{ key: 'esfera', name: '에스페라', image: `${BASE}/아케인심볼(에스페라).webp` },
],
authentic: [
{ key: 'cernium', name: '세르니움', image: `${BASE}/어센틱심볼(세르니움).webp` },
{ key: 'arcs', name: '아르크스', image: `${BASE}/어센틱심볼(아르크스).webp` },
{ key: 'odium', name: '오디움', image: `${BASE}/어센틱심볼(오디움).webp` },
{ key: 'dowongyeong', name: '도원경', image: `${BASE}/어센틱심볼(도원경).webp` },
{ key: 'arteria', name: '아르테리아', image: `${BASE}/어센틱심볼(아르테리아).webp` },
{ key: 'carcion', name: '카르시온', image: `${BASE}/어센틱심볼(카르시온).webp` },
],
grand: [
{ key: 'talahart', name: '탈라하트', image: `${BASE}/그랜드 어센틱심볼(탈라하트).webp` },
{ key: 'geardrock', name: '기어드락', image: `${BASE}/그랜드 어센틱심볼(기어드락).webp` },
],
}