보스 결정 사용자 페이지 + 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:
parent
793903668c
commit
7b6a821f36
15 changed files with 900 additions and 28 deletions
2
.env
2
.env
|
|
@ -13,7 +13,7 @@ S3_SECRET_KEY=u1m508WWLGQsn5ueRXV4qPID8OVqiz0Pnm9QDVeI
|
||||||
S3_BUCKET=maplestory
|
S3_BUCKET=maplestory
|
||||||
|
|
||||||
# 넥슨 API
|
# 넥슨 API
|
||||||
NEXON_API_KEY=test_d32f00908105a5803bf0ce5cf717747c0f06152c00f907ea7f9bb68d3541d2b6efe8d04e6d233bd35cf2fabdeb93fb0d
|
NEXON_API_KEY=live_d32f00908105a5803bf0ce5cf717747c8a9f571e4891660c5b4c69d7c34cbe70efe8d04e6d233bd35cf2fabdeb93fb0d
|
||||||
|
|
||||||
# 앱
|
# 앱
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
|
|
||||||
33
backend/routes/boss-crystal.js
Normal file
33
backend/routes/boss-crystal.js
Normal 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;
|
||||||
42
backend/routes/character.js
Normal file
42
backend/routes/character.js
Normal 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;
|
||||||
|
|
@ -3,6 +3,8 @@ import cors from 'cors';
|
||||||
import adminRoutes from './routes/admin.js';
|
import adminRoutes from './routes/admin.js';
|
||||||
import menuRoutes from './routes/menus.js';
|
import menuRoutes from './routes/menus.js';
|
||||||
import noticeRoutes from './routes/notices.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 { sequelize } from './lib/db.js';
|
||||||
import './models/index.js';
|
import './models/index.js';
|
||||||
|
|
||||||
|
|
@ -19,6 +21,8 @@ app.use(express.json());
|
||||||
|
|
||||||
app.use('/api/menus', menuRoutes);
|
app.use('/api/menus', menuRoutes);
|
||||||
app.use('/api/notices', noticeRoutes);
|
app.use('/api/notices', noticeRoutes);
|
||||||
|
app.use('/api/boss-crystal', bossCrystalRoutes);
|
||||||
|
app.use('/api/character', characterRoutes);
|
||||||
app.use('/api/admin', adminRoutes);
|
app.use('/api/admin', adminRoutes);
|
||||||
|
|
||||||
app.get('/api/health', (_req, res) => {
|
app.get('/api/health', (_req, res) => {
|
||||||
|
|
|
||||||
43
frontend/package-lock.json
generated
43
frontend/package-lock.json
generated
|
|
@ -12,6 +12,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.91.0",
|
"@tanstack/react-query": "^5.91.0",
|
||||||
|
"framer-motion": "^12.23.22",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0"
|
"react-router-dom": "^7.14.0"
|
||||||
|
|
@ -1874,6 +1875,33 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/fsevents": {
|
||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
|
|
@ -2444,6 +2472,21 @@
|
||||||
"node": "*"
|
"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": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@tanstack/react-query": "^5.91.0",
|
"@tanstack/react-query": "^5.91.0",
|
||||||
|
"framer-motion": "^12.23.22",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-router-dom": "^7.14.0"
|
"react-router-dom": "^7.14.0"
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,38 @@
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
import { Outlet, Link } from 'react-router-dom'
|
import { Outlet, Link } from 'react-router-dom'
|
||||||
import Footer from './Footer'
|
import Footer from './Footer'
|
||||||
|
|
||||||
|
const LayoutContext = createContext({ setFullscreen: () => {} })
|
||||||
|
|
||||||
|
export function useLayout() {
|
||||||
|
return useContext(LayoutContext)
|
||||||
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
|
const [fullscreen, setFullscreen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col">
|
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
|
||||||
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md">
|
<div className={`min-w-[1280px] bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col ${
|
||||||
<div className="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
|
fullscreen ? 'h-dvh' : 'min-h-screen'
|
||||||
<Link to="/" className="group flex items-center gap-2.5">
|
}`}>
|
||||||
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
|
||||||
<span className="text-lg font-bold tracking-tight">
|
<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">
|
||||||
</span>
|
<img src="/favicon.ico" alt="" className="w-8 h-8" />
|
||||||
</Link>
|
<span className="text-lg font-bold tracking-tight">
|
||||||
</div>
|
메이플스토리 유틸리티
|
||||||
</header>
|
</span>
|
||||||
<main className="flex-1 mx-auto w-full max-w-5xl px-6 py-10">
|
</Link>
|
||||||
<Outlet />
|
</div>
|
||||||
</main>
|
</header>
|
||||||
<Footer />
|
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${
|
||||||
</div>
|
fullscreen ? 'min-h-0 px-6 py-4' : 'px-6 pt-4 pb-10'
|
||||||
|
}`}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
{!fullscreen && <Footer />}
|
||||||
|
</div>
|
||||||
|
</LayoutContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="h-full">
|
||||||
<h1 className="text-2xl font-bold">주간 보스 결정 계산기</h1>
|
{isLoading ? (
|
||||||
<p className="text-gray-400">준비 중입니다.</p>
|
<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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import {
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { api } from '../../../api/client'
|
import { api } from '../../../api/client'
|
||||||
import { DIFFICULTIES, formatMeso } from './constants'
|
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
|
||||||
|
|
||||||
function BossCardContent({ boss, dragging = false }) {
|
function BossCardContent({ boss, dragging = false }) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -45,7 +45,12 @@ function BossCardContent({ boss, dragging = false }) {
|
||||||
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
|
||||||
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
|
const bd = boss.difficulties.find((x) => x.difficulty === d.key)
|
||||||
return (
|
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}
|
{d.label}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,37 @@
|
||||||
// 난이도 정의 (key, label, color) — 색상은 게임 내 난이도 배지 이미지와 매치
|
// 난이도 정의 (key, label, initial, colors)
|
||||||
export const DIFFICULTIES = [
|
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: 'easy', label: '이지', initial: 'E',
|
||||||
{ key: 'hard', label: '하드', color: 'text-fuchsia-300 border-fuchsia-400/40 bg-fuchsia-400/10' },
|
colors: { border: '#999999', bg: '#999999', text: '#ffffff' },
|
||||||
{ 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: '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) {
|
export function formatMeso(n) {
|
||||||
if (!n || n < 10000) return (n || 0).toLocaleString()
|
if (!n || n < 10000) return (n || 0).toLocaleString()
|
||||||
if (n >= 100_000_000) {
|
if (n >= 100_000_000) {
|
||||||
|
|
|
||||||
116
frontend/src/features/boss-crystal/user/BossSelector.jsx
Normal file
116
frontend/src/features/boss-crystal/user/BossSelector.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
302
frontend/src/features/boss-crystal/user/CharacterPanel.jsx
Normal file
302
frontend/src/features/boss-crystal/user/CharacterPanel.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
43
frontend/src/hooks/useFitText.js
Normal file
43
frontend/src/hooks/useFitText.js
Normal 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 }
|
||||||
|
}
|
||||||
95
frontend/src/hooks/useSmoothSticky.js
Normal file
95
frontend/src/hooks/useSmoothSticky.js
Normal 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
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,11 @@
|
||||||
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
|
--font-maple: "Maplestory", "Noto Sans KR", sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html, body, #root {
|
||||||
|
height: 100%;
|
||||||
|
background: #030712;
|
||||||
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
|
font-family: "Maplestory", "Noto Sans KR", system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
@ -34,3 +39,27 @@ input[type="number"]::-webkit-outer-spin-button {
|
||||||
input[type="number"] {
|
input[type="number"] {
|
||||||
-moz-appearance: textfield;
|
-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;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue