보스 수익/해방 계산기도 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 { useQuery, useQueries } from '@tanstack/react-query'
|
||||||
import { api } from '../../api/client'
|
import { api } from '../../api/client'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../components/Layout'
|
||||||
import CharacterPanel from './user/CharacterPanel'
|
import CharacterPanel from './user/CharacterPanel'
|
||||||
import BossSelector from './user/BossSelector'
|
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
|
const MAX_PER_CHARACTER = 12
|
||||||
|
|
||||||
export default function BossCrystal() {
|
export default function BossCrystal() {
|
||||||
const [characters, setCharacters] = useState(() => {
|
const characters = useBossStore((s) => s.characters)
|
||||||
const saved = localStorage.getItem(STORAGE_CHARS)
|
const selectedChar = useBossStore((s) => s.selectedChar)
|
||||||
return saved ? JSON.parse(saved) : []
|
const selections = useBossStore((s) => s.selections)
|
||||||
})
|
const addCharacter = useBossStore((s) => s.addCharacter)
|
||||||
const [selectedChar, setSelectedChar] = useState(() => {
|
const removeCharacter = useBossStore((s) => s.removeCharacter)
|
||||||
const saved = localStorage.getItem(STORAGE_CHARS)
|
const selectCharacter = useBossStore((s) => s.selectCharacter)
|
||||||
const list = saved ? JSON.parse(saved) : []
|
const reorderCharacters = useBossStore((s) => s.reorderCharacters)
|
||||||
return list[0]?.character_name || null
|
const setBossSelection = useBossStore((s) => s.setBossSelection)
|
||||||
})
|
const updateCharacter = useBossStore((s) => s.updateCharacter)
|
||||||
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()
|
const { setFullscreen } = useLayout()
|
||||||
|
|
@ -44,7 +31,7 @@ export default function BossCrystal() {
|
||||||
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
|
queryFn: () => api('/api/boss-crystal/bosses').catch(() => []),
|
||||||
})
|
})
|
||||||
|
|
||||||
// 저장된 캐릭터의 기본 정보(코디 이미지 포함) 새로고침
|
// 저장된 캐릭터의 기본 정보 새로고침
|
||||||
const charRefreshQueries = useQueries({
|
const charRefreshQueries = useQueries({
|
||||||
queries: characters.map((c) => ({
|
queries: characters.map((c) => ({
|
||||||
queryKey: ['character', 'basic', c.character_name],
|
queryKey: ['character', 'basic', c.character_name],
|
||||||
|
|
@ -57,63 +44,31 @@ export default function BossCrystal() {
|
||||||
})
|
})
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!charRefreshQueries.length) return
|
characters.forEach((c, i) => {
|
||||||
let changed = false
|
|
||||||
const next = characters.map((c, i) => {
|
|
||||||
const d = charRefreshQueries[i]?.data
|
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) {
|
if (d.character_image !== c.character_image || d.character_level !== c.character_level || d.job_name !== c.job_name) {
|
||||||
changed = true
|
updateCharacter(c.character_name, {
|
||||||
return { ...c, ...d }
|
character_image: d.character_image,
|
||||||
|
character_level: d.character_level,
|
||||||
|
job_name: d.job_name,
|
||||||
|
world_name: d.world_name,
|
||||||
|
ocid: d.ocid,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return c
|
|
||||||
})
|
})
|
||||||
if (changed) setCharacters(next)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [charRefreshQueries.map((q) => q.dataUpdatedAt).join(',')])
|
}, [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) => {
|
const handleBossChange = (bossId, sel) => {
|
||||||
if (!selectedChar) return
|
if (!selectedChar) return
|
||||||
setAllSelections((prev) => {
|
setBossSelection(selectedChar, bossId, sel)
|
||||||
const charSel = { ...(prev[selectedChar] || {}) }
|
|
||||||
if (sel === null) {
|
|
||||||
delete charSel[bossId]
|
|
||||||
} else {
|
|
||||||
charSel[bossId] = sel
|
|
||||||
}
|
|
||||||
return { ...prev, [selectedChar]: charSel }
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentSelections = selectedChar ? (allSelections[selectedChar] || {}) : {}
|
const currentSelections = selectedChar ? (selections[selectedChar] || {}) : {}
|
||||||
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
|
const currentSelectedCount = Object.values(currentSelections).filter(Boolean).length
|
||||||
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
|
const isMaxReached = currentSelectedCount >= MAX_PER_CHARACTER
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full">
|
<div className="h-full">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
|
|
@ -122,21 +77,19 @@ export default function BossCrystal() {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4 lg:grid-cols-[420px_1fr] h-full min-h-0">
|
<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">
|
<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
|
<CharacterPanel
|
||||||
characters={characters}
|
characters={characters}
|
||||||
selectedName={selectedChar}
|
selectedName={selectedChar}
|
||||||
allSelections={allSelections}
|
allSelections={selections}
|
||||||
bosses={bosses}
|
bosses={bosses}
|
||||||
onSelect={setSelectedChar}
|
onSelect={selectCharacter}
|
||||||
onAdd={handleAddCharacter}
|
onAdd={addCharacter}
|
||||||
onRemove={handleRemoveCharacter}
|
onRemove={removeCharacter}
|
||||||
onReorder={handleReorderCharacters}
|
onReorder={reorderCharacters}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
|
|
||||||
<div className="min-h-0">
|
<div className="min-h-0">
|
||||||
<BossSelector
|
<BossSelector
|
||||||
characterName={selectedChar}
|
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,
|
formatDate,
|
||||||
todayKST,
|
todayKST,
|
||||||
} from './data'
|
} from './data'
|
||||||
|
import { useLiberationStore } from './store'
|
||||||
import QuestSelector from './components/QuestSelector'
|
import QuestSelector from './components/QuestSelector'
|
||||||
import PointsInput from './components/PointsInput'
|
import PointsInput from './components/PointsInput'
|
||||||
import ProgressBar from './components/ProgressBar'
|
import ProgressBar from './components/ProgressBar'
|
||||||
|
|
@ -19,8 +20,6 @@ import DatePicker from '../../components/DatePicker'
|
||||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||||
import { useLayout } from '../../components/Layout'
|
import { useLayout } from '../../components/Layout'
|
||||||
|
|
||||||
const STORAGE_KEY = 'maple-liberation'
|
|
||||||
|
|
||||||
function makeEmptyWeekly() {
|
function makeEmptyWeekly() {
|
||||||
const bosses = {}
|
const bosses = {}
|
||||||
WEEKLY_BOSSES.forEach((b) => {
|
WEEKLY_BOSSES.forEach((b) => {
|
||||||
|
|
@ -81,50 +80,12 @@ export default function Liberation() {
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
|
||||||
const makeInitialSlot = () => ({
|
const calcMode = useLiberationStore((s) => s.calcMode)
|
||||||
startChapter: 0,
|
const state = useLiberationStore((s) => s[s.calcMode])
|
||||||
currentPoints: 0,
|
const setCalcMode = useLiberationStore((s) => s.setCalcMode)
|
||||||
startDate: dayjs(todayKST()).toISOString(),
|
const updateSlot = useLiberationStore((s) => s.updateSlot)
|
||||||
weekly: makeEmptyWeekly(),
|
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
||||||
schedulerWeeks: [{ id: 1, config: makeEmptyWeekly() }],
|
const setState = (updater) => updateSlot(updater)
|
||||||
})
|
|
||||||
|
|
||||||
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])
|
|
||||||
|
|
||||||
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||||
const priorConsumed = GENESIS_CHAPTERS
|
const priorConsumed = GENESIS_CHAPTERS
|
||||||
|
|
@ -305,7 +266,7 @@ export default function Liberation() {
|
||||||
|
|
||||||
const [resetOpen, setResetOpen] = useState(false)
|
const [resetOpen, setResetOpen] = useState(false)
|
||||||
const doReset = () => {
|
const doReset = () => {
|
||||||
setState(makeInitialSlot())
|
resetSlot()
|
||||||
setResetOpen(false)
|
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 } }
|
return { progress: { ...s.progress, [charId]: charProg } }
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
{
|
{ name: 'maple-symbol' },
|
||||||
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 || {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
))
|
))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue