보스 수익/해방 계산기도 zustand로 전환
- boss-crystal/store.js: characters/selectedChar/selections + persist - liberation/store.js: calcMode + simple/weekly slot + persist - 세 스토어(symbol 포함)에서 version/migrate/구 localStorage 호환 코드 제거 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
df0bb7d14b
commit
2d43b78ce4
5 changed files with 143 additions and 136 deletions
|
|
@ -1,36 +1,23 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useQuery, useQueries } from '@tanstack/react-query'
|
||||
import { api } from '../../api/client'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
import CharacterPanel from './user/CharacterPanel'
|
||||
import BossSelector from './user/BossSelector'
|
||||
import { useBossStore } from './store'
|
||||
|
||||
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 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)
|
||||
|
||||
// 풀스크린 모드 (푸터 숨김 + 내부 스크롤)
|
||||
const { setFullscreen } = useLayout()
|
||||
|
|
@ -44,7 +31,7 @@ export default function BossCrystal() {
|
|||
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
|
||||
})
|
||||
|
||||
// 저장된 캐릭터의 기본 정보(코디 이미지 포함) 새로고침
|
||||
// 저장된 캐릭터의 기본 정보 새로고침
|
||||
const charRefreshQueries = useQueries({
|
||||
queries: characters.map((c) => ({
|
||||
queryKey: ['character', 'basic', c.character_name],
|
||||
|
|
@ -57,63 +44,31 @@ export default function BossCrystal() {
|
|||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!charRefreshQueries.length) return
|
||||
let changed = false
|
||||
const next = characters.map((c, i) => {
|
||||
characters.forEach((c, i) => {
|
||||
const d = charRefreshQueries[i]?.data
|
||||
if (!d) return c
|
||||
if (!d) return
|
||||
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
|
||||
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,
|
||||
})
|
||||
}
|
||||
})
|
||||
if (changed) setCharacters(next)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
|
||||
|
||||
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 }
|
||||
})
|
||||
setBossSelection(selectedChar, bossId, sel)
|
||||
}
|
||||
|
||||
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
|
||||
const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
|
||||
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
|
||||
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-full">
|
||||
{isLoading ? (
|
||||
|
|
@ -122,21 +77,19 @@ export default function BossCrystal() {
|
|||
</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}
|
||||
allSelections={selections}
|
||||
bosses={bosses}
|
||||
onSelect={setSelectedChar}
|
||||
onAdd={handleAddCharacter}
|
||||
onRemove={handleRemoveCharacter}
|
||||
onReorder={handleReorderCharacters}
|
||||
onSelect={selectCharacter}
|
||||
onAdd={addCharacter}
|
||||
onRemove={removeCharacter}
|
||||
onReorder={reorderCharacters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
|
||||
<div className="min-h-0">
|
||||
<BossSelector
|
||||
characterName={selectedChar}
|
||||
|
|
|
|||
55
frontend/src/features/boss-crystal/store.js
Normal file
55
frontend/src/features/boss-crystal/store.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
/**
|
||||
* 보스 수익 계산기 상태
|
||||
* characters: [{ character_name, character_image, character_level, job_name, ... }]
|
||||
* selectedChar: 선택된 캐릭터 닉네임
|
||||
* selections: { [character_name]: { [bossId]: { difficulty, party } } }
|
||||
*/
|
||||
export const useBossStore = create(persist(
|
||||
(set) => ({
|
||||
characters: [],
|
||||
selectedChar: null,
|
||||
selections: {},
|
||||
|
||||
setCharacters: (next) => set((s) => ({
|
||||
characters: typeof next === 'function' ? next(s.characters) : next,
|
||||
})),
|
||||
|
||||
addCharacter: (char) => set((s) => {
|
||||
if (s.characters.find((c) => c.character_name === char.character_name)) return s
|
||||
return {
|
||||
characters: [...s.characters, char],
|
||||
selectedChar: char.character_name,
|
||||
}
|
||||
}),
|
||||
|
||||
removeCharacter: (name) => set((s) => {
|
||||
const next = s.characters.filter((c) => c.character_name !== name)
|
||||
const nextSel = { ...s.selections }
|
||||
delete nextSel[name]
|
||||
return {
|
||||
characters: next,
|
||||
selections: nextSel,
|
||||
selectedChar: s.selectedChar === name ? (next[0]?.character_name || null) : s.selectedChar,
|
||||
}
|
||||
}),
|
||||
|
||||
selectCharacter: (name) => set({ selectedChar: name }),
|
||||
|
||||
updateCharacter: (name, patch) => set((s) => ({
|
||||
characters: s.characters.map((c) => (c.character_name === name ? { ...c, ...patch } : c)),
|
||||
})),
|
||||
|
||||
reorderCharacters: (next) => set({ characters: next }),
|
||||
|
||||
setBossSelection: (charName, bossId, sel) => set((s) => {
|
||||
const charSel = { ...(s.selections[charName] || {}) }
|
||||
if (sel === null) delete charSel[bossId]
|
||||
else charSel[bossId] = sel
|
||||
return { selections: { ...s.selections, [charName]: charSel } }
|
||||
}),
|
||||
}),
|
||||
{ name: 'maple-boss-crystal' },
|
||||
))
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
formatDate,
|
||||
todayKST,
|
||||
} from './data'
|
||||
import { useLiberationStore } from './store'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
import PointsInput from './components/PointsInput'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
|
|
@ -19,8 +20,6 @@ import DatePicker from '../../components/DatePicker'
|
|||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
|
||||
const STORAGE_KEY = 'maple-liberation'
|
||||
|
||||
function makeEmptyWeekly() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
|
|
@ -81,50 +80,12 @@ export default function Liberation() {
|
|||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const makeInitialSlot = () => ({
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
|
||||
})
|
||||
|
||||
const [root, setRoot] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
// 구버전(단일 slot) → 새 구조로 마이그레이션
|
||||
if (!parsed.calcMode) {
|
||||
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
|
||||
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
|
||||
if (!parsed.schedulerWeeks) parsed.schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
|
||||
return { calcMode: 'simple', simple: parsed, weekly: makeInitialSlot() }
|
||||
}
|
||||
;['simple', 'weekly'].forEach((k) => {
|
||||
if (parsed[k] && !parsed[k].schedulerWeeks) {
|
||||
parsed[k].schedulerWeeks = [{ id: 1, config: makeEmptyWeekly() }]
|
||||
}
|
||||
})
|
||||
return parsed
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return { calcMode: 'simple', simple: makeInitialSlot(), weekly: makeInitialSlot() }
|
||||
})
|
||||
|
||||
const calcMode = root.calcMode
|
||||
const state = root[calcMode]
|
||||
const setState = (updater) => {
|
||||
setRoot((prev) => ({
|
||||
...prev,
|
||||
[prev.calcMode]: typeof updater === 'function' ? updater(prev[prev.calcMode]) : updater,
|
||||
}))
|
||||
}
|
||||
const setCalcMode = (mode) => setRoot((prev) => ({ ...prev, calcMode: mode }))
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(root))
|
||||
}, [root])
|
||||
const calcMode = useLiberationStore((s) => s.calcMode)
|
||||
const state = useLiberationStore((s) => s[s.calcMode])
|
||||
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
|
||||
const updateSlot = useLiberationStore((s) => s.updateSlot)
|
||||
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
||||
const setState = (updater) => updateSlot(updater)
|
||||
|
||||
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||
const priorConsumed = GENESIS_CHAPTERS
|
||||
|
|
@ -305,7 +266,7 @@ export default function Liberation() {
|
|||
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const doReset = () => {
|
||||
setState(makeInitialSlot())
|
||||
resetSlot()
|
||||
setResetOpen(false)
|
||||
}
|
||||
|
||||
|
|
|
|||
51
frontend/src/features/liberation/store.js
Normal file
51
frontend/src/features/liberation/store.js
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
import dayjs from 'dayjs'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, todayKST } from './data'
|
||||
|
||||
function makeEmptyWeekly() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||
})
|
||||
return {
|
||||
bosses,
|
||||
blackMage: { difficulty: 'none', party: 1, done: false },
|
||||
}
|
||||
}
|
||||
|
||||
function makeInitialSlot() {
|
||||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해방 계산기 상태
|
||||
* calcMode: 'simple' | 'weekly'
|
||||
* simple / weekly: 각 모드 독립 슬롯
|
||||
*/
|
||||
export const useLiberationStore = create(persist(
|
||||
(set) => ({
|
||||
calcMode: 'simple',
|
||||
simple: makeInitialSlot(),
|
||||
weekly: makeInitialSlot(),
|
||||
|
||||
setCalcMode: (mode) => set({ calcMode: mode }),
|
||||
|
||||
updateSlot: (patch) => set((s) => ({
|
||||
[s.calcMode]: typeof patch === 'function'
|
||||
? patch(s[s.calcMode])
|
||||
: { ...s[s.calcMode], ...patch },
|
||||
})),
|
||||
|
||||
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
|
||||
}),
|
||||
{ name: 'maple-liberation' },
|
||||
))
|
||||
|
||||
export { makeEmptyWeekly, makeInitialSlot }
|
||||
|
|
@ -105,18 +105,5 @@ export const useSymbolStore = create(persist(
|
|||
return { progress: { ...s.progress, [charId]: charProg } }
|
||||
}),
|
||||
}),
|
||||
{
|
||||
name: 'maple-symbol',
|
||||
version: 2,
|
||||
migrate: (persisted) => {
|
||||
// 이전 버전(단순 characters/selectedCharId 저장) 마이그레이션
|
||||
if (!persisted) return { characters: [], selectedCharId: null, progress: {} }
|
||||
return {
|
||||
characters: persisted.characters || [],
|
||||
selectedCharId: persisted.selectedCharId ?? null,
|
||||
progress: persisted.progress || {},
|
||||
selectedTabs: persisted.selectedTabs || {},
|
||||
}
|
||||
},
|
||||
},
|
||||
{ name: 'maple-symbol' },
|
||||
))
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue