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' }, ))