diff --git a/.env b/.env
index 4381644..366381b 100644
--- a/.env
+++ b/.env
@@ -13,7 +13,7 @@ S3_SECRET_KEY=u1m508WWLGQsn5ueRXV4qPID8OVqiz0Pnm9QDVeI
S3_BUCKET=maplestory
# 넥슨 API
-NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d
+NEXON_API_KEY=live_d32f00908105a5803bf0ce5cf717747c8a9f571e4891660c5b4c69d7c34cbe70efe8d04e6d233bd35cf2fabdeb93fb0d
# 앱
NODE_ENV=development
diff --git a/backend/routes/boss-crystal.js b/backend/routes/boss-crystal.js
new file mode 100644
index 0000000..f4579e2
--- /dev/null
+++ b/backend/routes/boss-crystal.js
@@ -0,0 +1,33 @@
+import { Router } from 'express';
+import { BossCrystalBoss, BossCrystalBossDifficulty } from '../models/index.js';
+import { getPublicUrl } from '../lib/s3.js';
+
+const router = Router();
+
+// 공개 보스 목록
+router.get('/bosses', async (_req, res) => {
+ try {
+ const bosses = await BossCrystalBoss.findAll({
+ order: [['sort_order', 'ASC'], ['id', 'ASC']],
+ include: [{ model: BossCrystalBossDifficulty, as: 'difficulties' }],
+ });
+ res.json(bosses.map((b) => {
+ const json = b.toJSON();
+ return {
+ id: json.id,
+ name: json.name,
+ image_url: json.image_path ? getPublicUrl(json.image_path) : null,
+ max_party_size: json.max_party_size,
+ difficulties: (json.difficulties || []).map((d) => ({
+ difficulty: d.difficulty,
+ crystal_price: Number(d.crystal_price),
+ })),
+ };
+ }));
+ } catch (err) {
+ console.error('보스 목록 조회 오류:', err.message);
+ res.status(500).json({ error: '보스 목록 조회 실패' });
+ }
+});
+
+export default router;
diff --git a/backend/routes/character.js b/backend/routes/character.js
new file mode 100644
index 0000000..3ffe994
--- /dev/null
+++ b/backend/routes/character.js
@@ -0,0 +1,42 @@
+import { Router } from 'express';
+import axios from 'axios';
+
+const router = Router();
+const NEXON_API_BASE = 'https://open.api.nexon.com';
+
+// 캐릭터 닉네임으로 정보 조회
+router.get('/search', async (req, res) => {
+ const { name } = req.query;
+ if (!name?.trim()) return res.status(400).json({ error: '캐릭터 닉네임을 입력해주세요' });
+
+ try {
+ // 1) ocid 조회
+ const { data: idData } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/id`, {
+ params: { character_name: name.trim() },
+ headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
+ });
+
+ // 2) basic 조회
+ const { data: basic } = await axios.get(`${NEXON_API_BASE}/maplestory/v1/character/basic`, {
+ params: { ocid: idData.ocid },
+ headers: { 'x-nxopen-api-key': process.env.NEXON_API_KEY },
+ });
+
+ res.json({
+ ocid: idData.ocid,
+ character_name: basic.character_name,
+ world_name: basic.world_name,
+ job_name: basic.character_class,
+ character_level: basic.character_level,
+ character_image: basic.character_image,
+ });
+ } catch (err) {
+ if (err.response?.status === 400) {
+ return res.status(404).json({ error: '존재하지 않는 캐릭터입니다' });
+ }
+ console.error('캐릭터 조회 오류:', err.response?.data || err.message);
+ res.status(500).json({ error: '캐릭터 조회 실패' });
+ }
+});
+
+export default router;
diff --git a/backend/server.js b/backend/server.js
index 123ee18..7f676dc 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -3,6 +3,8 @@ import cors from 'cors';
import adminRoutes from './routes/admin.js';
import menuRoutes from './routes/menus.js';
import noticeRoutes from './routes/notices.js';
+import bossCrystalRoutes from './routes/boss-crystal.js';
+import characterRoutes from './routes/character.js';
import { sequelize } from './lib/db.js';
import './models/index.js';
@@ -19,6 +21,8 @@ app.use(express.json());
app.use('/api/menus', menuRoutes);
app.use('/api/notices', noticeRoutes);
+app.use('/api/boss-crystal', bossCrystalRoutes);
+app.use('/api/character', characterRoutes);
app.use('/api/admin', adminRoutes);
app.get('/api/health', (_req, res) => {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 9486582..fabdfd9 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -12,6 +12,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.91.0",
+ "framer-motion": "^12.23.22",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
@@ -1874,6 +1875,33 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/framer-motion": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
+ "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-dom": "^12.38.0",
+ "motion-utils": "^12.36.0",
+ "tslib": "^2.4.0"
+ },
+ "peerDependencies": {
+ "@emotion/is-prop-valid": "*",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@emotion/is-prop-valid": {
+ "optional": true
+ },
+ "react": {
+ "optional": true
+ },
+ "react-dom": {
+ "optional": true
+ }
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2444,6 +2472,21 @@
"node": "*"
}
},
+ "node_modules/motion-dom": {
+ "version": "12.38.0",
+ "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
+ "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
+ "license": "MIT",
+ "dependencies": {
+ "motion-utils": "^12.36.0"
+ }
+ },
+ "node_modules/motion-utils": {
+ "version": "12.36.0",
+ "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
+ "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
+ "license": "MIT"
+ },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 2565050..d942e4a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -14,6 +14,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.91.0",
+ "framer-motion": "^12.23.22",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0"
diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx
index d2df32d..e1b82a0 100644
--- a/frontend/src/components/Layout.jsx
+++ b/frontend/src/components/Layout.jsx
@@ -1,23 +1,38 @@
+import { createContext, useContext, useState } from 'react'
import { Outlet, Link } from 'react-router-dom'
import Footer from './Footer'
+const LayoutContext = createContext({ setFullscreen: () => {} })
+
+export function useLayout() {
+ return useContext(LayoutContext)
+}
+
export default function Layout() {
+ const [fullscreen, setFullscreen] = useState(false)
+
return (
-
-
-
-
-

-
- 메이플스토리 유틸리티
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+

+
+ 메이플스토리 유틸리티
+
+
+
+
+
+
+
+ {!fullscreen &&
}
+
+
)
}
diff --git a/frontend/src/features/boss-crystal/BossCrystal.jsx b/frontend/src/features/boss-crystal/BossCrystal.jsx
index 2737240..cd6d7b7 100644
--- a/frontend/src/features/boss-crystal/BossCrystal.jsx
+++ b/frontend/src/features/boss-crystal/BossCrystal.jsx
@@ -1,8 +1,127 @@
+import { useState, useEffect } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { api } from '../../api/client'
+import { useLayout } from '../../components/Layout'
+import CharacterPanel from './user/CharacterPanel'
+import BossSelector from './user/BossSelector'
+
+const STORAGE_CHARS = 'maple-bc-characters'
+const STORAGE_SELECTIONS = 'maple-bc-selections'
+const MAX_PER_CHARACTER = 12
+
export default function BossCrystal() {
+ const [characters, setCharacters] = useState(() => {
+ const saved = localStorage.getItem(STORAGE_CHARS)
+ return saved ? JSON.parse(saved) : []
+ })
+ const [selectedChar, setSelectedChar] = useState(() => {
+ const saved = localStorage.getItem(STORAGE_CHARS)
+ const list = saved ? JSON.parse(saved) : []
+ return list[0]?.character_name || null
+ })
+ const [allSelections, setAllSelections] = useState(() => {
+ const saved = localStorage.getItem(STORAGE_SELECTIONS)
+ return saved ? JSON.parse(saved) : {}
+ })
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_CHARS, JSON.stringify(characters))
+ }, [characters])
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_SELECTIONS, JSON.stringify(allSelections))
+ }, [allSelections])
+
+ // 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
+ const { setFullscreen } = useLayout()
+ useEffect(() => {
+ setFullscreen(true)
+ return () => setFullscreen(false)
+ }, [setFullscreen])
+
+ const { data: bosses = [], isLoading } = useQuery({
+ queryKey: ['boss-crystal', 'bosses'],
+ queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
+ })
+
+ const handleAddCharacter = (char) => {
+ setCharacters((prev) => [...prev, char])
+ setSelectedChar(char.character_name)
+ }
+
+ const handleRemoveCharacter = (name) => {
+ setCharacters((prev) => {
+ const next = prev.filter((c) => c.character_name !== name)
+ if (selectedChar === name) {
+ setSelectedChar(next[0]?.character_name || null)
+ }
+ return next
+ })
+ setAllSelections((prev) => {
+ const next = { ...prev }
+ delete next[name]
+ return next
+ })
+ }
+
+ const handleReorderCharacters = (next) => {
+ setCharacters(next)
+ }
+
+ const handleBossChange = (bossId, sel) => {
+ if (!selectedChar) return
+ setAllSelections((prev) => {
+ const charSel = { ...(prev[selectedChar] || {}) }
+ if (sel === null) {
+ delete charSel[bossId]
+ } else {
+ charSel[bossId] = sel
+ }
+ return { ...prev, [selectedChar]: charSel }
+ })
+ }
+
+ const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
+ const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
+ const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
+
+
return (
-
-
주간 보스 결정 계산기
-
준비 중입니다.
+
+ {isLoading ? (
+
+ ) : (
+
+ {/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
+
+
+
+
+ {/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
+
+
+
+
+ )}
)
}
diff --git a/frontend/src/features/boss-crystal/admin/BossList.jsx b/frontend/src/features/boss-crystal/admin/BossList.jsx
index 7d42cab..f1e289c 100644
--- a/frontend/src/features/boss-crystal/admin/BossList.jsx
+++ b/frontend/src/features/boss-crystal/admin/BossList.jsx
@@ -11,7 +11,7 @@ import {
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { api } from '../../../api/client'
-import { DIFFICULTIES, formatMeso } from './constants'
+import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
function BossCardContent({ boss, dragging = false }) {
return (
@@ -45,7 +45,12 @@ function BossCardContent({ boss, dragging = false }) {
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
return (
-
+
{d.label}
)
diff --git a/frontend/src/features/boss-crystal/admin/constants.js b/frontend/src/features/boss-crystal/admin/constants.js
index d7fdba2..2117c8b 100644
--- a/frontend/src/features/boss-crystal/admin/constants.js
+++ b/frontend/src/features/boss-crystal/admin/constants.js
@@ -1,12 +1,37 @@
-// 난이도 정의 (key, label, color) — 색상은 게임 내 난이도 배지 이미지와 매치
+// 난이도 정의 (key, label, initial, colors)
export const DIFFICULTIES = [
- { key: 'easy', label: '이지', color: 'text-slate-300 border-slate-400/40 bg-slate-400/10' },
- { key: 'normal', label: '노말', color: 'text-sky-300 border-sky-400/40 bg-sky-400/10' },
- { key: 'hard', label: '하드', color: 'text-fuchsia-300 border-fuchsia-400/40 bg-fuchsia-400/10' },
- { key: 'chaos', label: '카오스', color: 'text-amber-300 border-amber-500/40 bg-amber-500/10' },
- { key: 'extreme', label: '익스트림', color: 'text-red-400 border-red-500/40 bg-red-500/10' },
+ {
+ key: 'easy', label: '이지', initial: 'E',
+ colors: { border: '#999999', bg: '#999999', text: '#ffffff' },
+ },
+ {
+ key: 'normal', label: '노말', initial: 'N',
+ colors: { border: '#33aabb', bg: '#33aabb', text: '#ffffff' },
+ },
+ {
+ key: 'hard', label: '하드', initial: 'H',
+ colors: { border: '#dd4489', bg: '#dd4489', text: '#ffffff' },
+ },
+ {
+ key: 'chaos', label: '카오스', initial: 'C',
+ colors: { border: '#ddbb88', bg: '#444444', text: '#ffddbb' },
+ },
+ {
+ key: 'extreme', label: '익스트림', initial: 'E',
+ colors: { border: '#ee3355', bg: '#444444', text: '#ee4455' },
+ },
]
+export function getDifficultyBadgeStyle(key) {
+ const diff = DIFFICULTIES.find((d) => d.key === key)
+ if (!diff) return {}
+ return {
+ borderColor: diff.colors.border,
+ backgroundColor: diff.colors.bg,
+ color: diff.colors.text,
+ }
+}
+
export function formatMeso(n) {
if (!n || n < 10000) return (n || 0).toLocaleString()
if (n >= 100_000_000) {
diff --git a/frontend/src/features/boss-crystal/user/BossSelector.jsx b/frontend/src/features/boss-crystal/user/BossSelector.jsx
new file mode 100644
index 0000000..45f45f1
--- /dev/null
+++ b/frontend/src/features/boss-crystal/user/BossSelector.jsx
@@ -0,0 +1,116 @@
+import Select from '../../../components/Select'
+import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from '../admin/constants'
+
+export default function BossSelector({ characterName, bosses, selections, onChange, maxReached, selectedCount, maxPerCharacter }) {
+ if (!characterName) {
+ return (
+
+ 좌측에서 캐릭터를 선택해주세요
+
+ )
+ }
+
+ if (bosses.length === 0) {
+ return (
+
+ 등록된 보스가 없습니다
+
+ )
+ }
+
+ return (
+
+ {/* 헤더 (고정) */}
+
+
보스
+
난이도
+
파티원 수
+
가격
+
+ {/* 목록 (스크롤) */}
+
+
+ {bosses.map((boss) => {
+ const availableDiffs = DIFFICULTIES.filter((d) =>
+ boss.difficulties.some((bd) => bd.difficulty === d.key)
+ )
+ const sel = selections[boss.id]
+ const bdInfo = sel ? boss.difficulties.find((bd) => bd.difficulty === sel.difficulty) : null
+ const partyN = sel?.party || 1
+ const revenue = bdInfo ? Math.floor(bdInfo.crystal_price / partyN) : 0
+
+ const partyOptions = Array.from({ length: boss.max_party_size }, (_, i) => i + 1).map((n) => ({
+ value: n,
+ label: `${n}인`,
+ }))
+
+ // 한도 도달 + 이 보스가 선택 안 됐으면 비활성화
+ const disabled = maxReached && !sel
+
+ return (
+
+ {/* 보스 이미지 + 이름 */}
+
+
+

+
+
{boss.name}
+
+
+ {/* 난이도 - 한 줄 고정 */}
+
+ {availableDiffs.map((d) => {
+ const active = sel?.difficulty === d.key
+ return (
+
+ )
+ })}
+
+
+ {/* 파티 인원 - 커스텀 Select */}
+
+ {sel ? (
+
+
+ {/* 수익 */}
+
+ {sel ? formatMeso(revenue) : '-'}
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend/src/features/boss-crystal/user/CharacterPanel.jsx b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx
new file mode 100644
index 0000000..d2630c9
--- /dev/null
+++ b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx
@@ -0,0 +1,302 @@
+import { useState } from 'react'
+import { useMutation } from '@tanstack/react-query'
+import { Reorder } from 'framer-motion'
+import { api } from '../../../api/client'
+import ConfirmDialog from '../../../components/ConfirmDialog'
+import { useFitText } from '../../../hooks/useFitText'
+import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
+
+const MAX_PER_CHARACTER = 12
+const MAX_PER_ACCOUNT = 90
+
+function CharacterContent({ char, selections, bosses }) {
+ const selectedBosses = Object.entries(selections || {})
+ .filter(([, sel]) => sel)
+ .map(([bossId, sel]) => {
+ const boss = bosses.find((b) => b.id === Number(bossId))
+ if (!boss) return null
+ const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
+ if (!bd) return null
+ return {
+ boss,
+ difficulty: sel.difficulty,
+ revenue: Math.floor(bd.crystal_price / sel.party),
+ }
+ })
+ .filter(Boolean)
+ .sort((a, b) => b.revenue - a.revenue)
+
+ const visibleBosses = selectedBosses.slice(0, MAX_PER_CHARACTER)
+ const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
+ const count = selectedBosses.length
+
+ return (
+
+
+
+ {char.character_image ? (
+

+ ) : (
+
?
+ )}
+
+
+
+
+ {char.character_name}
+ Lv.{char.character_level} · {char.job_name}
+
+
+ {visibleBosses.length > 0 ? (
+
+ {visibleBosses.map((item) => {
+ const diff = DIFFICULTIES.find((d) => d.key === item.difficulty)
+ return (
+
+
+

+
+
+
+ )
+ })}
+
+ ) : (
+
보스 미선택
+ )}
+
+
+
+
+
0 ? 'text-gray-400' : 'text-gray-600'}`}>
+ {count}/{MAX_PER_CHARACTER}
+
+
0 ? 'text-emerald-300' : 'text-gray-700'}`}>
+ {count > 0 ? formatMeso(totalRevenue) : '-'}
+
+
+
+ )
+}
+
+function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
+ const [dragged, setDragged] = useState(false)
+
+ return (
+ setDragged(true)}
+ onDragEnd={() => {
+ // 다음 click 이벤트 후에 reset
+ setTimeout(() => setDragged(false), 0)
+ }}
+ onClick={(e) => {
+ if (dragged) return
+ if (e.target.closest('button')) return
+ onSelect(char.character_name)
+ }}
+ className={`group relative rounded-xl border cursor-grab active:cursor-grabbing select-none ${
+ isSelected
+ ? 'border-emerald-500/40 bg-emerald-500/[0.08]'
+ : 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
+ }`}
+ >
+ {/* 드래그 핸들 아이콘 (시각적 표시용) */}
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default function CharacterPanel({
+ characters, selectedName, allSelections, bosses,
+ onSelect, onAdd, onRemove, onReorder,
+}) {
+ const [name, setName] = useState('')
+ const [error, setError] = useState('')
+ const [confirmRemove, setConfirmRemove] = useState(null)
+
+ const searchMutation = useMutation({
+ mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
+ onSuccess: (data) => {
+ if (characters.find((c) => c.character_name === data.character_name)) {
+ setError('이미 추가된 캐릭터입니다')
+ return
+ }
+ onAdd(data)
+ setName('')
+ setError('')
+ },
+ onError: (err) => setError(err.message),
+ })
+
+ const handleSubmit = (e) => {
+ e.preventDefault()
+ if (!name.trim()) return
+ setError('')
+ searchMutation.mutate(name.trim())
+ }
+
+ // 총합 계산
+ const charResults = characters.map((char) => {
+ const charSel = allSelections[char.character_name] || {}
+ const items = Object.entries(charSel)
+ .filter(([, sel]) => sel)
+ .map(([bossId, sel]) => {
+ const boss = bosses.find((b) => b.id === Number(bossId))
+ if (!boss) return null
+ const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
+ if (!bd) return null
+ return Math.floor(bd.crystal_price / sel.party)
+ })
+ .filter(Boolean)
+ .sort((a, b) => b - a)
+ .slice(0, MAX_PER_CHARACTER)
+
+ return { count: items.length, revenue: items.reduce((s, v) => s + v, 0) }
+ })
+
+ const totalCount = charResults.reduce((s, r) => s + r.count, 0)
+ const totalRevenue = charResults.reduce((s, r) => s + r.revenue, 0)
+ const accountUsage = Math.min(totalCount, MAX_PER_ACCOUNT)
+ const usagePct = Math.min((accountUsage / MAX_PER_ACCOUNT) * 100, 100)
+ const totalText = formatMeso(totalRevenue)
+ const { containerRef: totalContainerRef, textRef: totalTextRef } = useFitText({
+ maxFontSize: 32,
+ minFontSize: 14,
+ value: totalText,
+ })
+
+ return (
+
+ {/* 총 수익 카드 (고정) */}
+
+
+
+
+
+ 총 결정 개수
+ MAX_PER_ACCOUNT ? 'text-amber-400' : 'text-gray-200'}`}>
+ {accountUsage}/{MAX_PER_ACCOUNT}
+
+
+
+
MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
+ style={{ width: `${usagePct}%` }}
+ />
+
+ {totalCount > MAX_PER_ACCOUNT && (
+
⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과
+ )}
+
+
+
+ {/* 캐릭터 추가 (고정) */}
+
+
+ {/* 캐릭터 목록 (스크롤) */}
+ {characters.length > 0 && (
+
+
+ {characters.map((char) => (
+
+ ))}
+
+
+ )}
+
+
setConfirmRemove(null)}
+ onConfirm={() => {
+ onRemove(confirmRemove.character_name)
+ setConfirmRemove(null)
+ }}
+ title="캐릭터 삭제"
+ description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
+ confirmText="삭제"
+ destructive
+ />
+
+ )
+}
diff --git a/frontend/src/hooks/useFitText.js b/frontend/src/hooks/useFitText.js
new file mode 100644
index 0000000..640de5c
--- /dev/null
+++ b/frontend/src/hooks/useFitText.js
@@ -0,0 +1,43 @@
+import { useEffect, useRef, useState } from 'react'
+
+/**
+ * 텍스트가 컨테이너에 들어가도록 자동으로 폰트 크기 축소
+ * @param {number} maxFontSize - 최대 폰트 크기 (px)
+ * @param {number} minFontSize - 최소 폰트 크기 (px)
+ * @param {string} value - 텍스트 (변경 감지용)
+ */
+export function useFitText({ maxFontSize = 30, minFontSize = 12, value }) {
+ const containerRef = useRef(null)
+ const textRef = useRef(null)
+ const [fontSize, setFontSize] = useState(maxFontSize)
+
+ useEffect(() => {
+ if (!containerRef.current || !textRef.current) return
+
+ const fit = () => {
+ const container = containerRef.current
+ const text = textRef.current
+ if (!container || !text) return
+
+ // 일단 최대 크기로 시도
+ let size = maxFontSize
+ text.style.fontSize = `${size}px`
+
+ // 컨테이너보다 크면 줄여나감
+ while (text.scrollWidth > container.clientWidth && size > minFontSize) {
+ size -= 1
+ text.style.fontSize = `${size}px`
+ }
+
+ setFontSize(size)
+ }
+
+ fit()
+
+ const ro = new ResizeObserver(fit)
+ ro.observe(containerRef.current)
+ return () => ro.disconnect()
+ }, [value, maxFontSize, minFontSize])
+
+ return { containerRef, textRef, fontSize }
+}
diff --git a/frontend/src/hooks/useSmoothSticky.js b/frontend/src/hooks/useSmoothSticky.js
new file mode 100644
index 0000000..2a44493
--- /dev/null
+++ b/frontend/src/hooks/useSmoothSticky.js
@@ -0,0 +1,95 @@
+import { useCallback, useRef } from 'react'
+
+/**
+ * 구글폼 사이드 패널처럼 스크롤을 부드럽게 따라오는 sticky 효과
+ * - 부모 컨테이너의 위/아래 경계를 넘지 않음
+ * - lerp(선형보간)로 부드러운 움직임
+ *
+ * Callback ref 패턴이라 element가 마운트되는 시점에 자동 setup
+ *
+ * @returns {Function} ref 콜백
+ */
+export function useSmoothSticky({ offsetTop = 80, bottomMargin = 16, lerp = 0.18 } = {}) {
+ const cleanupRef = useRef(null)
+
+ return useCallback((el) => {
+ // 이전 element의 cleanup
+ if (cleanupRef.current) {
+ cleanupRef.current()
+ cleanupRef.current = null
+ }
+
+ if (!el || !el.parentElement) return
+
+ let rafId = null
+ let target = 0
+ let current = 0
+
+ const calcTarget = () => {
+ const containerRect = el.parentElement.getBoundingClientRect()
+ const containerHeight = el.parentElement.offsetHeight
+ const elementHeight = el.offsetHeight
+ const viewportHeight = window.innerHeight
+ const availableSpace = viewportHeight - offsetTop - bottomMargin
+ const maxOffset = Math.max(0, containerHeight - elementHeight - bottomMargin)
+
+ let desired
+ if (elementHeight <= availableSpace) {
+ // 패널이 viewport에 들어감 → 상단에 sticky
+ desired = Math.max(0, offsetTop - containerRect.top)
+ } else {
+ // 패널이 viewport보다 큼 → 자연스럽게 스크롤되다가 하단이 viewport 하단에 닿으면 멈춤
+ desired = Math.max(0, viewportHeight - bottomMargin - containerRect.top - elementHeight)
+ }
+
+ target = Math.min(desired, maxOffset)
+ }
+
+ const tick = () => {
+ const diff = target - current
+ if (Math.abs(diff) > 0.3) {
+ current += diff * lerp
+ el.style.transform = `translate3d(0, ${current}px, 0)`
+ rafId = requestAnimationFrame(tick)
+ } else {
+ current = target
+ el.style.transform = `translate3d(0, ${current}px, 0)`
+ rafId = null
+ }
+ }
+
+ const startTick = () => {
+ if (rafId === null) rafId = requestAnimationFrame(tick)
+ }
+
+ const onScroll = () => {
+ calcTarget()
+ startTick()
+ }
+
+ // 초기 위치 설정
+ calcTarget()
+ current = target
+ el.style.transform = `translate3d(0, ${current}px, 0)`
+ el.style.willChange = 'transform'
+
+ window.addEventListener('scroll', onScroll, { passive: true })
+ window.addEventListener('resize', onScroll)
+
+ const ro = new ResizeObserver(onScroll)
+ ro.observe(el)
+ ro.observe(el.parentElement)
+
+ cleanupRef.current = () => {
+ if (rafId !== null) cancelAnimationFrame(rafId)
+ window.removeEventListener('scroll', onScroll)
+ window.removeEventListener('resize', onScroll)
+ ro.disconnect()
+ if (el.isConnected) {
+ el.style.transform = ''
+ el.style.willChange = ''
+ }
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [])
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 9d417b4..e2a8b08 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -5,6 +5,11 @@
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
}
+html, body, #root {
+ height: 100%;
+ background: #030712;
+}
+
html {
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
}
@@ -34,3 +39,27 @@ input[type="number"]::-webkit-outer-spin-button {
input[type="number"] {
-moz-appearance: textfield;
}
+
+/* 커스텀 스크롤바 */
+* {
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
+}
+*::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+*::-webkit-scrollbar-track {
+ background: transparent;
+}
+*::-webkit-scrollbar-thumb {
+ background: rgba(255, 255, 255, 0.08);
+ border-radius: 4px;
+ transition: background 0.2s;
+}
+*::-webkit-scrollbar-thumb:hover {
+ background: rgba(255, 255, 255, 0.18);
+}
+*::-webkit-scrollbar-corner {
+ background: transparent;
+}