From 33de50bc2de087771141010f5c2136f577210c20 Mon Sep 17 00:00:00 2001 From: caadiq Date: Wed, 15 Apr 2026 13:06:30 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=AC=EB=B3=BC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20+=20=EC=8B=AC?= =?UTF-8?q?=EB=B3=BC=20=ED=85=8C=EC=9D=B4=EB=B8=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모델 2개 추가: Symbol (type/region/image/max_level/daily_default/weekly_default/sort_order) + SymbolLevel (symbol_id/level/required_count/meso_cost) - /admin/symbol 라우트와 심볼 목록/편집 UI (결정석 관리 스타일 차용) - 심볼 목록 dnd-kit 드래그앤드랍 순서 변경 - 심볼 폼: 이미지 업로더, 종류/지역 입력, 만렙·일퀘·주간퀘 입력 - 레벨별 필요 개수/메소 테이블 (만렙에 따라 행 자동 조정) - 메소 입력 쉼표 포매팅 + "N억 N,NNN만" 한글 요약 (amber, 고정 높이) Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/models/index.js | 12 +- backend/models/symbol/Symbol.js | 23 ++ backend/models/symbol/SymbolLevel.js | 16 ++ frontend/src/features/symbol/Symbol.jsx | 2 +- frontend/src/features/symbol/SymbolAdmin.jsx | 13 + .../src/features/symbol/admin/SymbolForm.jsx | 270 ++++++++++++++++++ .../src/features/symbol/admin/SymbolList.jsx | 156 ++++++++++ 7 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 backend/models/symbol/Symbol.js create mode 100644 backend/models/symbol/SymbolLevel.js create mode 100644 frontend/src/features/symbol/SymbolAdmin.jsx create mode 100644 frontend/src/features/symbol/admin/SymbolForm.jsx create mode 100644 frontend/src/features/symbol/admin/SymbolList.jsx diff --git a/backend/models/index.js b/backend/models/index.js index 2adedb7..1422c4b 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -2,6 +2,8 @@ import { Image } from './Image.js'; import { Menu } from './Menu.js'; import { BossCrystalBoss } from './boss-crystal/Boss.js'; import { BossCrystalBossDifficulty } from './boss-crystal/BossDifficulty.js'; +import { Symbol } from './symbol/Symbol.js'; +import { SymbolLevel } from './symbol/SymbolLevel.js'; // Menu <-> Image Menu.belongsTo(Image, { foreignKey: 'image_id', as: 'image', onDelete: 'SET NULL' }); @@ -14,4 +16,12 @@ BossCrystalBoss.hasMany(BossCrystalBossDifficulty, { }); BossCrystalBossDifficulty.belongsTo(BossCrystalBoss, { foreignKey: 'boss_id', as: 'boss' }); -export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty }; +// Symbol <-> SymbolLevel +Symbol.hasMany(SymbolLevel, { + foreignKey: 'symbol_id', + as: 'levels', + onDelete: 'CASCADE', +}); +SymbolLevel.belongsTo(Symbol, { foreignKey: 'symbol_id', as: 'symbol' }); + +export { Image, Menu, BossCrystalBoss, BossCrystalBossDifficulty, Symbol, SymbolLevel }; diff --git a/backend/models/symbol/Symbol.js b/backend/models/symbol/Symbol.js new file mode 100644 index 0000000..2998f06 --- /dev/null +++ b/backend/models/symbol/Symbol.js @@ -0,0 +1,23 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../../lib/db.js'; + +export const Symbol = sequelize.define('Symbol', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + type: { + type: DataTypes.ENUM('아케인', '어센틱', '그랜드 어센틱'), + allowNull: false, + }, + region: { type: DataTypes.STRING(32), allowNull: false }, + image: { type: DataTypes.STRING(255), allowNull: true }, + max_level: { type: DataTypes.TINYINT, allowNull: false }, + daily_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 }, + weekly_default: { type: DataTypes.SMALLINT, allowNull: false, defaultValue: 0 }, + sort_order: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 }, +}, { + tableName: 'sym_symbols', + underscored: true, + indexes: [ + { unique: true, fields: ['type', 'region'] }, + { fields: ['sort_order'] }, + ], +}); diff --git a/backend/models/symbol/SymbolLevel.js b/backend/models/symbol/SymbolLevel.js new file mode 100644 index 0000000..07089a0 --- /dev/null +++ b/backend/models/symbol/SymbolLevel.js @@ -0,0 +1,16 @@ +import { DataTypes } from 'sequelize'; +import { sequelize } from '../../lib/db.js'; + +export const SymbolLevel = sequelize.define('SymbolLevel', { + id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true }, + symbol_id: { type: DataTypes.INTEGER, allowNull: false }, + level: { type: DataTypes.TINYINT, allowNull: false }, + required_count: { type: DataTypes.SMALLINT, allowNull: false }, + meso_cost: { type: DataTypes.INTEGER, allowNull: false }, +}, { + tableName: 'sym_levels', + underscored: true, + indexes: [ + { unique: true, fields: ['symbol_id', 'level'] }, + ], +}); diff --git a/frontend/src/features/symbol/Symbol.jsx b/frontend/src/features/symbol/Symbol.jsx index f9000e1..9fced6c 100644 --- a/frontend/src/features/symbol/Symbol.jsx +++ b/frontend/src/features/symbol/Symbol.jsx @@ -126,7 +126,7 @@ function SymbolCard({ symbol, equipped }) { />
- + + } /> + } /> + } /> + + ) +} diff --git a/frontend/src/features/symbol/admin/SymbolForm.jsx b/frontend/src/features/symbol/admin/SymbolForm.jsx new file mode 100644 index 0000000..768da14 --- /dev/null +++ b/frontend/src/features/symbol/admin/SymbolForm.jsx @@ -0,0 +1,270 @@ +import { useState, useRef } from 'react' +import { useNavigate, useParams } from 'react-router-dom' +import Select from '../../../components/Select' +import ConfirmDialog from '../../../components/ConfirmDialog' + +const TYPE_OPTIONS = [ + { value: '아케인', label: '아케인' }, + { value: '어센틱', label: '어센틱' }, + { value: '그랜드 어센틱', label: '그랜드 어센틱' }, +] + +const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition' + +function formatMesoKorean(n) { + if (!n || n <= 0) return '' + const eok = Math.floor(n / 100_000_000) + const man = Math.floor((n % 100_000_000) / 10_000) + const parts = [] + if (eok) parts.push(`${eok}억`) + if (man) parts.push(`${man.toLocaleString()}만`) + if (!parts.length) return `${n.toLocaleString()}` + return parts.join(' ') +} + +function MesoInput({ value, onChange, ...rest }) { + const display = value === '' || value == null ? '' : Number(String(value).replace(/[^\d]/g, '')).toLocaleString() + const korean = formatMesoKorean(Number(String(value).replace(/[^\d]/g, '')) || 0) + return ( +
+ { + const digits = e.target.value.replace(/[^\d]/g, '') + onChange(digits) + }} + className={`${inputCls} tabular-nums text-right`} + {...rest} + /> +
{korean || '\u00A0'}
+
+ ) +} + +function Field({ label, hint, error, required, children }) { + return ( +
+
+ + {hint && {hint}} +
+ {children} + {error &&
{error}
} +
+ ) +} + +export default function SymbolForm() { + const navigate = useNavigate() + const { id } = useParams() + const isEdit = !!id + const fileInputRef = useRef(null) + + const [type, setType] = useState('아케인') + const [region, setRegion] = useState('') + const [maxLevel, setMaxLevel] = useState('') + const [dailyDefault, setDailyDefault] = useState('') + const [weeklyDefault, setWeeklyDefault] = useState('') + const [imageFile, setImageFile] = useState(null) + const [imagePreview, setImagePreview] = useState(null) + const [levels, setLevels] = useState([]) + const [confirmDelete, setConfirmDelete] = useState(false) + + const handleFile = (e) => { + const file = e.target.files?.[0] + if (!file) return + setImageFile(file) + setImagePreview(URL.createObjectURL(file)) + } + + const updateLevel = (idx, field, val) => { + setLevels((prev) => prev.map((l, i) => (i === idx ? { ...l, [field]: val } : l))) + } + + const adjustLevelRows = (newMax) => { + const n = Number(newMax) + if (!n || n < 2) return + setLevels((prev) => { + const rows = Array.from({ length: n - 1 }, (_, i) => { + const level = i + 1 + return prev.find((l) => l.level === level) || { level, required_count: '', meso_cost: '' } + }) + return rows + }) + } + + return ( +
+
+

{isEdit ? '심볼 편집' : '심볼 추가'}

+

심볼 정보와 레벨별 필요 개수/메소를 입력합니다

+
+ + {/* 기본 정보 */} +
+
기본 정보
+ + + + + +
+
+ + setRegion(e.target.value)} + className={inputCls} + placeholder="소멸의 여로" + /> + +
+ +
+ + { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }} + className={inputCls} + min="2" + /> + + + setDailyDefault(e.target.value)} + className={inputCls} + /> + + + setWeeklyDefault(e.target.value)} + className={inputCls} + /> + +
+ +
+
+ + {/* 레벨별 설정 */} +
+
+
레벨별 필요 개수 · 메소
+
레벨 N → N+1 업그레이드 기준 (만렙-1행)
+
+ +
+ + + + + + + + + + {levels.map((l, idx) => ( + + + + + + ))} + +
레벨필요 심볼 수메소
+ Lv.{l.level} + + {l.level + 1} + + updateLevel(idx, 'required_count', e.target.value)} + className={`${inputCls} max-w-36`} + placeholder="0" + /> + +
+ updateLevel(idx, 'meso_cost', v)} + placeholder="0" + /> +
+
+
+
+ + {/* 하단 버튼 */} +
+
+ {isEdit && ( + + )} +
+
+ + +
+
+ + setConfirmDelete(false)} + onConfirm={() => setConfirmDelete(false)} + title="심볼 삭제" + description="이 심볼을 삭제하시겠습니까?\n레벨별 데이터도 함께 삭제됩니다." + confirmText="삭제" + destructive + /> +
+ ) +} diff --git a/frontend/src/features/symbol/admin/SymbolList.jsx b/frontend/src/features/symbol/admin/SymbolList.jsx new file mode 100644 index 0000000..71e865c --- /dev/null +++ b/frontend/src/features/symbol/admin/SymbolList.jsx @@ -0,0 +1,156 @@ +import { useState, useEffect } from 'react' +import { Link } from 'react-router-dom' +import { + DndContext, DragOverlay, closestCenter, PointerSensor, KeyboardSensor, + useSensor, useSensors, +} from '@dnd-kit/core' +import { + SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy, + 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 }, +] + +const TYPE_COLOR = { + '아케인': { text: 'text-violet-300', bg: 'bg-violet-500/15', border: 'border-violet-500/30' }, + '어센틱': { text: 'text-sky-300', bg: 'bg-sky-500/15', border: 'border-sky-500/30' }, + '그랜드 어센틱': { text: 'text-amber-300', bg: 'bg-amber-500/15', border: 'border-amber-500/30' }, +} + +function SymbolCardContent({ symbol, dragging = false }) { + const color = TYPE_COLOR[symbol.type] + return ( +
+
+ + + + + +
+
+
+ {symbol.image_url ? ( + + ) : ( + ? + )} +
+
+
+

{symbol.region}

+ + {symbol.type} + +
+
+ 만렙 {symbol.max_level} + 일퀘 {symbol.daily_default} + 주간퀘 {symbol.weekly_default} +
+
+
+
+ ) +} + +function SortableSymbolCard({ symbol }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging, setActivatorNodeRef } = useSortable({ + id: symbol.id, + transition: { duration: 200, easing: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }, + }) + const style = { transform: CSS.Transform.toString(transform), transition } + return ( +
+
+ ) +} + +export default function SymbolList() { + const [items, setItems] = useState(MOCK_SYMBOLS) + const [activeId, setActiveId] = useState(null) + + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) + ) + + 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 activeSymbol = items.find((s) => s.id === activeId) + + return ( +
+
+
+

심볼 관리

+

심볼 정보 및 레벨별 필요 개수/메소를 관리합니다

+
+ + + + 심볼 추가 + +
+ + {items.length === 0 ? ( +
+
🔮
+

등록된 심볼이 없습니다

+ + 첫 심볼 추가하기 → + +
+ ) : ( + setActiveId(e.active.id)} + onDragCancel={() => setActiveId(null)} + onDragEnd={handleDragEnd} + > + s.id)} strategy={rectSortingStrategy}> +
+ {items.map((s) => ( + + ))} +
+
+ + {activeSymbol ? : null} + +
+ )} +
+ ) +}