심볼 계산기/관리자 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:
parent
33de50bc2d
commit
eb4369d8fb
9 changed files with 520 additions and 90 deletions
|
|
@ -5,6 +5,7 @@ import { convertAndUpload, deleteFromS3 } from '../services/image.js';
|
||||||
import { getPublicUrl } from '../lib/s3.js';
|
import { getPublicUrl } from '../lib/s3.js';
|
||||||
import { sequelize } from '../lib/db.js';
|
import { sequelize } from '../lib/db.js';
|
||||||
import bossCrystalRouter from './admin/boss-crystal.js';
|
import bossCrystalRouter from './admin/boss-crystal.js';
|
||||||
|
import symbolRouter from './admin/symbol.js';
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
const upload = multer({
|
const upload = multer({
|
||||||
|
|
@ -35,6 +36,7 @@ router.use(requireAdmin);
|
||||||
|
|
||||||
// 기능별 sub-router
|
// 기능별 sub-router
|
||||||
router.use('/boss-crystal', bossCrystalRouter);
|
router.use('/boss-crystal', bossCrystalRouter);
|
||||||
|
router.use('/symbol', symbolRouter);
|
||||||
|
|
||||||
/* ── 이미지 관리 ── */
|
/* ── 이미지 관리 ── */
|
||||||
|
|
||||||
|
|
|
||||||
237
backend/routes/admin/symbol.js
Normal file
237
backend/routes/admin/symbol.js
Normal 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
39
backend/routes/symbol.js
Normal 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;
|
||||||
|
|
@ -6,6 +6,7 @@ import noticeRoutes from './routes/notices.js';
|
||||||
import bossCrystalRoutes from './routes/boss-crystal.js';
|
import bossCrystalRoutes from './routes/boss-crystal.js';
|
||||||
import characterRoutes from './routes/character.js';
|
import characterRoutes from './routes/character.js';
|
||||||
import imageRoutes from './routes/images.js';
|
import imageRoutes from './routes/images.js';
|
||||||
|
import symbolRoutes from './routes/symbol.js';
|
||||||
import { sequelize } from './lib/db.js';
|
import { sequelize } from './lib/db.js';
|
||||||
import './models/index.js';
|
import './models/index.js';
|
||||||
|
|
||||||
|
|
@ -25,6 +26,7 @@ app.use('/api/notices', noticeRoutes);
|
||||||
app.use('/api/boss-crystal', bossCrystalRoutes);
|
app.use('/api/boss-crystal', bossCrystalRoutes);
|
||||||
app.use('/api/character', characterRoutes);
|
app.use('/api/character', characterRoutes);
|
||||||
app.use('/api/images', imageRoutes);
|
app.use('/api/images', imageRoutes);
|
||||||
|
app.use('/api/symbols', symbolRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,14 @@ export async function convertAndUpload(buffer) {
|
||||||
export async function deleteFromS3(path) {
|
export async function deleteFromS3(path) {
|
||||||
await deleteObject(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 };
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query'
|
import { useQuery, useMutation } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
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 }) {
|
function CharacterCard({ char, active, onSelect, onRemove }) {
|
||||||
return (
|
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 }) {
|
function SymbolCard({ symbol, equipped }) {
|
||||||
// 임시 목업 데이터
|
const [weeklyCount, setWeeklyCount] = useState(3)
|
||||||
const level = equipped ? 7 : 0
|
const [dailyDone, setDailyDone] = useState(false)
|
||||||
const maxLevel = 20
|
// 임시 목업 값 (계산 기능 미구현)
|
||||||
const growth = equipped ? 120 : 0
|
const level = equipped ? 0 : 0
|
||||||
const requireGrowth = 60
|
const growth = 0
|
||||||
const remainingSymbols = 540
|
const requireGrowth = symbol.levels?.[0]?.required_count || 0
|
||||||
const remainingMeso = 128_000_000
|
const remainingSymbols = '-'
|
||||||
const daysLeft = equipped ? 84 : '-'
|
const remainingMeso = '-'
|
||||||
const completeDate = equipped ? '2026년 07월 09일 (목)' : '미장착'
|
const daysLeft = '-'
|
||||||
|
const completeDate = '-'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`rounded-2xl border p-5 transition ${
|
<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="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">
|
<div className="w-14 h-14 rounded-lg bg-gray-950 overflow-hidden shrink-0 flex items-center justify-center">
|
||||||
<img
|
{symbol.image_url && (
|
||||||
src={symbol.image}
|
<img
|
||||||
alt={symbol.name}
|
src={symbol.image_url}
|
||||||
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
|
alt={symbol.region}
|
||||||
style={{ imageRendering: 'pixelated' }}
|
className={`w-12 h-12 object-contain ${!equipped ? 'grayscale opacity-50' : ''}`}
|
||||||
/>
|
style={{ imageRendering: 'pixelated' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<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">
|
<div className="text-sm text-gray-400 tabular-nums mt-0.5">
|
||||||
Lv.<span className="text-emerald-300 font-bold text-base">{level}</span>
|
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>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
{/* 진행도 바 */}
|
{/* 진행도 바 */}
|
||||||
|
|
@ -114,23 +123,35 @@ function SymbolCard({ symbol, equipped }) {
|
||||||
</div>
|
</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">
|
<div className="space-y-1">
|
||||||
<label className="block text-xs text-gray-400">일퀘 획득</label>
|
<label className="block text-xs text-gray-400">일퀘 획득</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={equipped ? '20' : '0'}
|
defaultValue={equipped ? String(symbol.daily_default) : '0'}
|
||||||
disabled={!equipped}
|
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"
|
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>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="block text-xs text-gray-400">주간퀘 획득</label>
|
<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
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
defaultValue={equipped ? '45' : '0'}
|
defaultValue="0"
|
||||||
disabled={!equipped}
|
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"
|
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="divide-y divide-white/5 text-base">
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">남은 심볼</span>
|
<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>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">필요 메소</span>
|
<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>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">체납 메소</span>
|
<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>
|
||||||
<div className="flex justify-between py-2">
|
<div className="flex justify-between py-2">
|
||||||
<span className="text-gray-400">남은 일수</span>
|
<span className="text-gray-400">남은 일수</span>
|
||||||
|
|
@ -174,7 +195,28 @@ export default function Symbol() {
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
||||||
const STORAGE_KEY = 'maple-symbol'
|
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(() => {
|
const [characters, setCharacters] = useState(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
|
|
@ -195,8 +237,8 @@ export default function Symbol() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId }))
|
localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId }))
|
||||||
}, [characters, selectedCharId])
|
}, [characters, selectedCharId])
|
||||||
const symbols = SYMBOLS[tab]
|
const symbols = allSymbols.filter((s) => s.type === tab)
|
||||||
const tabInfo = SYMBOL_TABS.find((t) => t.key === tab)
|
const tabInfo = tabs.find((t) => t.key === tab)
|
||||||
|
|
||||||
const searchMutation = useMutation({
|
const searchMutation = useMutation({
|
||||||
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
|
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
|
||||||
|
|
@ -277,7 +319,7 @@ export default function Symbol() {
|
||||||
|
|
||||||
{/* 심볼 타입 탭 */}
|
{/* 심볼 타입 탭 */}
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{SYMBOL_TABS.map((t) => (
|
{tabs.map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t.key}
|
key={t.key}
|
||||||
type="button"
|
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'
|
: '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>
|
<span className="text-base font-semibold">{t.label}</span>
|
||||||
</button>
|
</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">
|
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{symbols.map((s, i) => (
|
{symbols.map((s, i) => (
|
||||||
<SymbolCard key={s.key} symbol={s} equipped={isEquipped(i)} />
|
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(i)} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { useNavigate, useParams } from 'react-router-dom'
|
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 Select from '../../../components/Select'
|
||||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||||
|
|
||||||
|
|
@ -60,6 +62,7 @@ function Field({ label, hint, error, required, children }) {
|
||||||
|
|
||||||
export default function SymbolForm() {
|
export default function SymbolForm() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const isEdit = !!id
|
const isEdit = !!id
|
||||||
const fileInputRef = useRef(null)
|
const fileInputRef = useRef(null)
|
||||||
|
|
@ -71,8 +74,37 @@ export default function SymbolForm() {
|
||||||
const [weeklyDefault, setWeeklyDefault] = useState('')
|
const [weeklyDefault, setWeeklyDefault] = useState('')
|
||||||
const [imageFile, setImageFile] = useState(null)
|
const [imageFile, setImageFile] = useState(null)
|
||||||
const [imagePreview, setImagePreview] = useState(null)
|
const [imagePreview, setImagePreview] = useState(null)
|
||||||
|
const [existingImageUrl, setExistingImageUrl] = useState(null)
|
||||||
const [levels, setLevels] = useState([])
|
const [levels, setLevels] = useState([])
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false)
|
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 handleFile = (e) => {
|
||||||
const file = e.target.files?.[0]
|
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 (
|
return (
|
||||||
<div className="max-w-2xl mx-auto space-y-6">
|
<div className="max-w-2xl mx-auto space-y-6">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -111,15 +202,15 @@ export default function SymbolForm() {
|
||||||
<Field label="심볼 이미지" required={!isEdit}>
|
<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">
|
<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">
|
<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 ? (
|
{displayImage ? (
|
||||||
<img src={imagePreview} 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 text-gray-700">+</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 text-gray-300">
|
<div className="text-sm font-medium text-gray-300">
|
||||||
{imagePreview ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
||||||
{imageFile && (
|
{imageFile && (
|
||||||
|
|
@ -249,19 +340,27 @@ export default function SymbolForm() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ConfirmDialog
|
||||||
open={confirmDelete}
|
open={confirmDelete}
|
||||||
onClose={() => setConfirmDelete(false)}
|
onClose={() => setConfirmDelete(false)}
|
||||||
onConfirm={() => setConfirmDelete(false)}
|
onConfirm={() => { setConfirmDelete(false); deleteMutation.mutate() }}
|
||||||
title="심볼 삭제"
|
title="심볼 삭제"
|
||||||
description="이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다."
|
description={'이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다.'}
|
||||||
confirmText="삭제"
|
confirmText="삭제"
|
||||||
destructive
|
destructive
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
|
DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor,
|
||||||
useSensor, useSensors,
|
useSensor, useSensors,
|
||||||
|
|
@ -9,13 +10,7 @@ import {
|
||||||
arrayMove,
|
arrayMove,
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
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 },
|
|
||||||
]
|
|
||||||
|
|
||||||
const TYPE_COLOR = {
|
const TYPE_COLOR = {
|
||||||
'아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' },
|
'아케인': { 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 }) {
|
function SymbolCardContent({ symbol, dragging = false }) {
|
||||||
const color = TYPE_COLOR[symbol.type]
|
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
|
||||||
dragging
|
dragging
|
||||||
|
|
@ -88,21 +83,44 @@ function SortableSymbolCard({ symbol }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SymbolList() {
|
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)
|
const [activeId, setActiveId] = useState(null)
|
||||||
|
useEffect(() => { setItems(symbols) }, [symbols])
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
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 handleDragEnd = (event) => {
|
||||||
const { active, over } = event
|
const { active, over } = event
|
||||||
setActiveId(null)
|
setActiveId(null)
|
||||||
if (!over || active.id === over.id) return
|
if (!over || active.id === over.id) return
|
||||||
const oldIdx = items.findIndex((s) => s.id === active.id)
|
const oldIdx = items.findIndex((s) => s.id === active.id)
|
||||||
const newIdx = items.findIndex((s) => s.id === over.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)
|
const activeSymbol = items.find((s) => s.id === activeId)
|
||||||
|
|
@ -123,7 +141,13 @@ export default function SymbolList() {
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</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="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>
|
<div className="text-5xl mb-3 opacity-30">🔮</div>
|
||||||
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
|
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
|
||||||
|
|
|
||||||
|
|
@ -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` },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
Loading…
Add table
Reference in a new issue