2026-04-19 11:12:19 +09:00
|
|
|
import { useEffect, useLayoutEffect } 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'
|
2026-04-16 19:20:50 +09:00
|
|
|
import { useBossStore } from './store'
|
2026-04-13 19:17:49 +09:00
|
|
|
|
|
|
|
|
const MAX_PER_CHARACTER = 12
|
|
|
|
|
|
2026-04-13 15:27:04 +09:00
|
|
|
export default function BossCrystal() {
|
2026-04-16 19:20:50 +09:00
|
|
|
const characters = useBossStore((s) => s.characters)
|
|
|
|
|
const selectedChar = useBossStore((s) => s.selectedChar)
|
|
|
|
|
const selections = useBossStore((s) => s.selections)
|
|
|
|
|
const addCharacter = useBossStore((s) => s.addCharacter)
|
|
|
|
|
const removeCharacter = useBossStore((s) => s.removeCharacter)
|
|
|
|
|
const selectCharacter = useBossStore((s) => s.selectCharacter)
|
|
|
|
|
const reorderCharacters = useBossStore((s) => s.reorderCharacters)
|
|
|
|
|
const setBossSelection = useBossStore((s) => s.setBossSelection)
|
|
|
|
|
const updateCharacter = useBossStore((s) => s.updateCharacter)
|
2026-04-13 19:17:49 +09:00
|
|
|
|
|
|
|
|
// 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
|
|
|
|
|
const { setFullscreen } = useLayout()
|
2026-04-19 11:12:19 +09:00
|
|
|
useLayoutEffect(() => {
|
2026-04-13 19:17:49 +09:00
|
|
|
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 19:20:50 +09:00
|
|
|
// 저장된 캐릭터의 기본 정보 새로고침
|
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(() => {
|
2026-04-16 19:20:50 +09:00
|
|
|
characters.forEach((c, i) => {
|
2026-04-16 13:48:03 +09:00
|
|
|
const d = charRefreshQueries[i]?.data
|
2026-04-16 19:20:50 +09:00
|
|
|
if (!d) return
|
2026-04-16 13:48:03 +09:00
|
|
|
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
|
2026-04-16 19:20:50 +09:00
|
|
|
updateCharacter(c.character_name, {
|
|
|
|
|
character_image: d.character_image,
|
|
|
|
|
character_level: d.character_level,
|
|
|
|
|
job_name: d.job_name,
|
|
|
|
|
world_name: d.world_name,
|
|
|
|
|
ocid: d.ocid,
|
|
|
|
|
})
|
2026-04-16 13:48:03 +09:00
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
|
|
|
|
|
|
2026-04-13 19:17:49 +09:00
|
|
|
const handleBossChange = (bossId, sel) => {
|
|
|
|
|
if (!selectedChar) return
|
2026-04-16 19:20:50 +09:00
|
|
|
setBossSelection(selectedChar, bossId, sel)
|
2026-04-13 19:17:49 +09:00
|
|
|
}
|
|
|
|
|
|
2026-04-16 19:20:50 +09:00
|
|
|
const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
|
2026-04-13 19:17:49 +09:00
|
|
|
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 ? (
|
2026-04-18 12:15:04 +09:00
|
|
|
<div
|
|
|
|
|
className="rounded-2xl border p-16 text-center"
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--panel-bg)',
|
|
|
|
|
borderColor: 'var(--panel-border)',
|
|
|
|
|
boxShadow: 'var(--panel-shadow)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin mx-auto" style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }} />
|
2026-04-13 19:17:49 +09:00
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
|
2026-04-18 12:15:04 +09:00
|
|
|
<div
|
|
|
|
|
className="rounded-2xl border p-4 min-h-0 max-h-full self-start overflow-hidden flex flex-col"
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--panel-bg)',
|
|
|
|
|
borderColor: 'var(--panel-border)',
|
|
|
|
|
boxShadow: 'var(--panel-shadow)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
2026-04-13 19:17:49 +09:00
|
|
|
<CharacterPanel
|
|
|
|
|
characters={characters}
|
|
|
|
|
selectedName={selectedChar}
|
2026-04-16 19:20:50 +09:00
|
|
|
allSelections={selections}
|
2026-04-13 19:17:49 +09:00
|
|
|
bosses={bosses}
|
2026-04-16 19:20:50 +09:00
|
|
|
onSelect={selectCharacter}
|
|
|
|
|
onAdd={addCharacter}
|
|
|
|
|
onRemove={removeCharacter}
|
|
|
|
|
onReorder={reorderCharacters}
|
2026-04-13 19:17:49 +09:00
|
|
|
/>
|
|
|
|
|
</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>
|
|
|
|
|
)
|
|
|
|
|
}
|