보스 결정 사용자 페이지 + UI/UX 개선

레이아웃:
- 풀스크린 모드 컨텍스트 (BossCrystal 페이지에서 푸터 숨김 + viewport 고정)
- 캐릭터 패널: 자연 높이 + viewport 한도 + 내부 목록 스크롤
- 보스 패널: 헤더 고정 + 목록 내부 스크롤
- 커스텀 스크롤바 (전역)

캐릭터 패널:
- framer-motion Reorder로 드래그앤드롭 정렬
- 가로 캐릭터 행 + 6x2 보스 그리드 + 난이도 영문 첫글자 뱃지
- 총 수익에 ResizeObserver 기반 자동 폰트 fit
- 캐릭터 삭제 시 첫번째 자동 선택, 입력 재개 시 에러 메시지 자동 제거

기능:
- 공개 보스/캐릭터 API 추가
- API 키 라이브 키로 변경

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 19:17:49 +09:00
parent 793903668c
commit 7b6a821f36
15 changed files with 900 additions and 28 deletions

2
.env
View file

@ -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

View file

@ -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;

View file

@ -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;

View file

@ -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) => {

View file

@ -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",

View file

@ -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"

View file

@ -1,11 +1,23 @@
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 (
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col">
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md">
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
<div className={`min-w-[1280px] bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col ${
fullscreen ? 'h-dvh' : 'min-h-screen'
}`}>
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
<Link to="/" className="group flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-8 h-8" />
<span className="text-lg font-bold tracking-tight">
@ -14,10 +26,13 @@ export default function Layout() {
</Link>
</div>
</header>
<main className="flex-1 mx-auto w-full max-w-5xl px-6 py-10">
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
fullscreen ? 'min-h-0 px-6 py-4' : 'px-6 pt-4 pb-10'
}`}>
<Outlet />
</main>
<Footer />
{!fullscreen && <Footer />}
</div>
</LayoutContext.Provider>
)
}

View file

@ -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 (
<div className="space-y-4">
<h1 className="text-2xl font-bold">주간 보스 결정 계산기</h1>
<p className="text-gray-400">준비 중입니다.</p>
<div className="h-full">
{isLoading ? (
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-16 text-center">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin mx-auto" />
</div>
) : (
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
{/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col">
<CharacterPanel
characters={characters}
selectedName={selectedChar}
allSelections={allSelections}
bosses={bosses}
onSelect={setSelectedChar}
onAdd={handleAddCharacter}
onRemove={handleRemoveCharacter}
onReorder={handleReorderCharacters}
/>
</div>
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
<div className="min-h-0">
<BossSelector
characterName={selectedChar}
bosses={bosses}
selections={currentSelections}
onChange={handleBossChange}
maxReached={isMaxReached}
selectedCount={currentSelectedCount}
maxPerCharacter={MAX_PER_CHARACTER}
/>
</div>
</div>
)}
</div>
)
}

View file

@ -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 (
<span key={d.key} className={`text-[10px] px-1.5 py-0.5 rounded border ${d.color}`} title={formatMeso(bd.crystal_price)}>
<span
key={d.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border"
style={getDifficultyBadgeStyle(d.key)}
title={`${d.label} - ${formatMeso(bd.crystal_price)}`}
>
{d.label}
</span>
)

View file

@ -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) {

View file

@ -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 (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
좌측에서 캐릭터를 선택해주세요
</div>
)
}
if (bosses.length === 0) {
return (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center text-sm text-gray-500">
등록된 보스가 없습니다
</div>
)
}
return (
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden flex flex-col h-full">
{/* 헤더 (고정) */}
<div className="flex items-center gap-3 px-3 py-3 bg-gray-950/60 border-b border-white/5 text-base font-semibold text-gray-300 shrink-0">
<div className="w-52 shrink-0">보스</div>
<div className="flex-1">난이도</div>
<div className="w-20 shrink-0 text-center">파티원 </div>
<div className="w-32 shrink-0 text-right">가격</div>
</div>
{/* 목록 (스크롤) */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="divide-y divide-white/5">
{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 (
<div
key={boss.id}
className={`flex items-center gap-3 px-3 py-3 transition ${
disabled ? 'opacity-30 pointer-events-none' : ''
}`}
>
{/* 보스 이미지 + 이름 */}
<div className="flex items-center gap-2.5 w-52 shrink-0">
<div className="shrink-0 w-11 h-11 rounded-lg bg-gray-900 overflow-hidden">
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
</div>
<span className="text-base font-medium leading-tight whitespace-nowrap overflow-hidden text-ellipsis">{boss.name}</span>
</div>
{/* 난이도 - 한 줄 고정 */}
<div className="flex-1 flex items-center gap-2 flex-nowrap min-w-0">
{availableDiffs.map((d) => {
const active = sel?.difficulty === d.key
return (
<button
key={d.key}
type="button"
tabIndex={-1}
onClick={(e) => {
e.currentTarget.blur()
if (active) {
onChange(boss.id, null)
} else {
onChange(boss.id, { difficulty: d.key, party: partyN })
}
}}
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
title={d.label}
>
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
</button>
)
})}
</div>
{/* 파티 인원 - 커스텀 Select */}
<div className="w-20 shrink-0">
{sel ? (
<Select
value={partyN}
onChange={(val) => onChange(boss.id, { ...sel, party: val })}
options={partyOptions}
align="right"
/>
) : (
<div className="text-xs text-gray-700 text-center">-</div>
)}
</div>
{/* 수익 */}
<div className={`w-32 shrink-0 text-right text-sm font-medium tabular-nums ${sel ? 'text-emerald-300' : 'text-gray-700'}`}>
{sel ? formatMeso(revenue) : '-'}
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -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 (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="shrink-0 overflow-hidden flex items-center justify-center" style={{ width: 96, height: 96 }}>
{char.character_image ? (
<img
src={char.character_image}
alt=""
className="w-full h-full object-contain scale-[3] origin-center select-none"
style={{ imageRendering: 'pixelated' }}
draggable={false}
/>
) : (
<span className="text-gray-700 text-4xl">?</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-base font-semibold truncate">{char.character_name}</span>
<span className="text-xs text-gray-500 truncate">Lv.{char.character_level} · {char.job_name}</span>
</div>
{visibleBosses.length > 0 ? (
<div className="grid grid-cols-6 gap-1.5">
{visibleBosses.map((item) => {
const diff = DIFFICULTIES.find((d) => d.key === item.difficulty)
return (
<div
key={item.boss.id}
className="space-y-0.5"
title={`${item.boss.name} ${diff?.label || ''} - ${formatMeso(item.revenue)}`}
>
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
</div>
<div className="flex justify-center">
<div
className="text-[9px] font-bold leading-none rounded border w-3.5 h-3.5 flex items-center justify-center"
style={getDifficultyBadgeStyle(item.difficulty)}
>
{diff?.initial}
</div>
</div>
</div>
)
})}
</div>
) : (
<div className="text-xs text-gray-600 italic h-[58px] flex items-center">보스 미선택</div>
)}
</div>
</div>
<div className="flex items-center justify-between border-t border-white/5 pt-2">
<div className={`text-sm tabular-nums ${count > 0 ? 'text-gray-400' : 'text-gray-600'}`}>
{count}<span className="text-gray-700">/{MAX_PER_CHARACTER}</span>
</div>
<div className={`text-sm font-semibold tabular-nums whitespace-nowrap ${count > 0 ? 'text-emerald-300' : 'text-gray-700'}`}>
{count > 0 ? formatMeso(totalRevenue) : '-'}
</div>
</div>
</div>
)
}
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
const [dragged, setDragged] = useState(false)
return (
<Reorder.Item
value={char}
onDragStart={() => 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'
}`}
>
{/* 드래그 핸들 아이콘 (시각적 표시용) */}
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-600 pointer-events-none">
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="3" r="1.2" />
<circle cx="9" cy="3" r="1.2" />
<circle cx="3" cy="8" r="1.2" />
<circle cx="9" cy="8" r="1.2" />
<circle cx="3" cy="13" r="1.2" />
<circle cx="9" cy="13" r="1.2" />
</svg>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(char) }}
className="absolute top-2 right-2 z-10 w-6 h-6 rounded text-gray-600 hover:text-red-400 hover:bg-red-500/10 transition opacity-0 group-hover:opacity-100 flex items-center justify-center text-base"
aria-label="삭제"
>
×
</button>
<div className="pl-8 pr-3 py-2.5">
<CharacterContent char={char} selections={selections} bosses={bosses} />
</div>
</Reorder.Item>
)
}
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 (
<div className="flex flex-col gap-4 min-h-0 flex-1">
{/* 총 수익 카드 (고정) */}
<div className="rounded-2xl border border-emerald-500/30 bg-gradient-to-br from-emerald-500/15 to-emerald-500/5 p-4 space-y-3 shrink-0">
<div>
<div className="text-xs text-emerald-200/80"> 주간 수익</div>
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
<div
ref={totalTextRef}
className="font-bold text-emerald-300 leading-tight whitespace-nowrap inline-block"
>
{totalText}
</div>
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400"> 결정 개수</span>
<span className={`tabular-nums font-semibold ${totalCount > MAX_PER_ACCOUNT ? 'text-amber-400' : 'text-gray-200'}`}>
{accountUsage}<span className="text-gray-600 font-normal">/{MAX_PER_ACCOUNT}</span>
</span>
</div>
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
<div
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${usagePct}%` }}
/>
</div>
{totalCount > MAX_PER_ACCOUNT && (
<p className="text-[10px] text-amber-400"> 한도 {totalCount - MAX_PER_ACCOUNT} 초과</p>
)}
</div>
</div>
{/* 캐릭터 추가 (고정) */}
<div className="shrink-0">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); if (error) setError('') }}
placeholder="캐릭터 닉네임 입력"
className="flex-1 min-w-0 rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition"
/>
<button
type="submit"
disabled={searchMutation.isPending}
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-4 py-2 text-sm font-medium transition shrink-0"
>
{searchMutation.isPending ? '...' : '추가'}
</button>
</form>
{error && <p className="text-xs text-red-400 mt-1.5">{error}</p>}
</div>
{/* 캐릭터 목록 (스크롤) */}
{characters.length > 0 && (
<div className="flex-1 min-h-0 overflow-y-auto -mx-4 px-4">
<Reorder.Group
axis="y"
values={characters}
onReorder={onReorder}
className="space-y-2"
>
{characters.map((char) => (
<CharacterItem
key={char.character_name}
char={char}
isSelected={selectedName === char.character_name}
selections={allSelections[char.character_name] || {}}
bosses={bosses}
onSelect={onSelect}
onRemove={setConfirmRemove}
/>
))}
</Reorder.Group>
</div>
)}
<ConfirmDialog
open={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
onRemove(confirmRemove.character_name)
setConfirmRemove(null)
}}
title="캐릭터 삭제"
description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
confirmText="삭제"
destructive
/>
</div>
)
}

View file

@ -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 }
}

View file

@ -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
}, [])
}

View file

@ -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;
}