diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 859efc2..52de130 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -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); /* ── 이미지 관리 ── */ diff --git a/backend/routes/admin/symbol.js b/backend/routes/admin/symbol.js new file mode 100644 index 0000000..8d79217 --- /dev/null +++ b/backend/routes/admin/symbol.js @@ -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; diff --git a/backend/routes/symbol.js b/backend/routes/symbol.js new file mode 100644 index 0000000..d31315c --- /dev/null +++ b/backend/routes/symbol.js @@ -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; diff --git a/backend/server.js b/backend/server.js index 25cd9e5..538e876 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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) => { diff --git a/backend/services/image.js b/backend/services/image.js index 1c3e966..b620ffb 100644 --- a/backend/services/image.js +++ b/backend/services/image.js @@ -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 }; +} diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx index 9fced6c..1fce00c 100644 --- a/frontend/src/features/symbol/Symbol.jsx +++ b/frontend/src/features/symbol/Symbol.jsx @@ -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
- return -} - 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 (
- {symbol.name} + {symbol.image_url && ( + {symbol.region} + )}
-
{symbol.name}
+
{symbol.region}
Lv.{level} - / {maxLevel} + / {symbol.max_level}
+
{/* 진행도 바 */} @@ -114,23 +123,35 @@ function SymbolCard({ symbol, equipped }) {
{/* 획득량 입력 */} -
+
+ @@ -141,15 +162,15 @@ function SymbolCard({ symbol, equipped }) {
남은 심볼 - {remainingSymbols.toLocaleString()} + {remainingSymbols}
필요 메소 - {remainingMeso.toLocaleString()} + {remainingMeso}
체납 메소 - {(equipped ? 18_000_000 : 0).toLocaleString()} + -
남은 일수 @@ -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() { {/* 심볼 타입 탭 */}
- {SYMBOL_TABS.map((t) => ( + {tabs.map((t) => ( ))} @@ -297,7 +343,7 @@ export default function Symbol() { {/* 심볼 카드 그리드 */}
{symbols.map((s, i) => ( - + ))}
diff --git a/frontend/src/features/symbol/admin/SymbolForm.jsx b/frontend/src/features/symbol/admin/SymbolForm.jsx index 768da14..6ce8d8d 100644 --- a/frontend/src/features/symbol/admin/SymbolForm.jsx +++ b/frontend/src/features/symbol/admin/SymbolForm.jsx @@ -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 (
@@ -111,15 +202,15 @@ export default function SymbolForm() {
+ {error && ( +
+ {error} +
+ )} + setConfirmDelete(false)} - onConfirm={() => setConfirmDelete(false)} + onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }} title="심볼 삭제" - description="이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다." + description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'} confirmText="삭제" destructive /> diff --git a/frontend/src/features/symbol/admin/SymbolList.jsx b/frontend/src/features/symbol/admin/SymbolList.jsx index 71e865c..a0735b4 100644 --- a/frontend/src/features/symbol/admin/SymbolList.jsx +++ b/frontend/src/features/symbol/admin/SymbolList.jsx @@ -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 (
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() {
- {items.length === 0 ? ( + {isLoading ? ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ ))} +
+ ) : items.length === 0 ? (
🔮

등록된 심볼이 없습니다

diff --git a/frontend/src/features/symbol/data.js b/frontend/src/features/symbol/data.js deleted file mode 100644 index d565f07..0000000 --- a/frontend/src/features/symbol/data.js +++ /dev/null @@ -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` }, - ], -}