심볼 계산기/관리자 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 { 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);
|
||||
|
||||
/* ── 이미지 관리 ── */
|
||||
|
||||
|
|
|
|||
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 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) => {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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