diff --git a/frontend/src/features/boss-crystal/BossCrystal.jsx b/frontend/src/features/boss-crystal/BossCrystal.jsx
index 4aed18e..f0ce9a9 100644
--- a/frontend/src/features/boss-crystal/BossCrystal.jsx
+++ b/frontend/src/features/boss-crystal/BossCrystal.jsx
@@ -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 }
+ 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,
+ })
}
- return c
})
- 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 (
{isLoading ? (
@@ -122,21 +77,19 @@ export default function BossCrystal() {
) : (
- {/* 좌측: 캐릭터 + 결과 통합 (총 수익/추가 고정 + 목록 스크롤) */}
- {/* 우측: 보스 선택 (헤더 고정 + 목록 스크롤) */}
({
+ 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' },
+))
diff --git a/frontend/src/features/liberation/Liberation.jsx b/frontend/src/features/liberation/Liberation.jsx
index 66dd762..84d047a 100644
--- a/frontend/src/features/liberation/Liberation.jsx
+++ b/frontend/src/features/liberation/Liberation.jsx
@@ -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)
}
diff --git a/frontend/src/features/liberation/store.js b/frontend/src/features/liberation/store.js
new file mode 100644
index 0000000..b6f3d51
--- /dev/null
+++ b/frontend/src/features/liberation/store.js
@@ -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 }
diff --git a/frontend/src/features/symbol/store.js b/frontend/src/features/symbol/store.js
index 6fa2eb7..c55a19f 100644
--- a/frontend/src/features/symbol/store.js
+++ b/frontend/src/features/symbol/store.js
@@ -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' },
))