2026-04-13 19:17:49 +09:00
|
|
|
import { useState, useEffect } from 'react'
|
2026-04-16 13:48:03 +09:00
|
|
|
import { useQuery, useQueries } from '@tanstack/react-query'
|
2026-04-13 19:17:49 +09:00
|
|
|
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
|
|
|
|
|
|
2026-04-13 15:27:04 +09:00
|
|
|
export default function BossCrystal() {
|
2026-04-13 19:17:49 +09:00
|
|
|
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(() => []),
|
|
|
|
|
})
|
|
|
|
|
|
2026-04-16 13:48:03 +09:00
|
|
|
// 저장된 캐릭터의 기본 정보(코디 이미지 포함) 새로고침
|
|
|
|
|
const charRefreshQueries = useQueries({
|
|
|
|
|
queries: characters.map((c) => ({
|
|
|
|
|
queryKey: ['character', 'basic', c.character_name],
|
|
|
|
|
queryFn: () => api(`/api/character/search?name=${encodeURIComponent(c.character_name)}`),
|
|
|
|
|
enabled: !!c.character_name,
|
|
|
|
|
refetchOnMount: 'always',
|
|
|
|
|
staleTime: 0,
|
|
|
|
|
retry: false,
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!charRefreshQueries.length) return
|
|
|
|
|
let changed = false
|
|
|
|
|
const next = characters.map((c, i) => {
|
|
|
|
|
const d = charRefreshQueries[i]?.data
|
|
|
|
|
if (!d) return c
|
|
|
|
|
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
|
|
|
|
|
changed = true
|
|
|
|
|
return { ...c, ...d }
|
|
|
|
|
}
|
|
|
|
|
return c
|
|
|
|
|
})
|
|
|
|
|
if (changed) setCharacters(next)
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
|
|
|
|
|
|
2026-04-13 19:17:49 +09:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-13 15:27:04 +09:00
|
|
|
return (
|
2026-04-13 19:17:49 +09:00
|
|
|
<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>
|
|
|
|
|
)}
|
2026-04-13 15:27:04 +09:00
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|