심볼 계산기에 zustand 도입 + 캐릭터별 상태 저장

- zustand + persist 미들웨어로 캐릭터 목록·선택 상태·심볼 진행 저장
- 스토어 스키마: progress[charId][symbolId] = { level, growth, daily, weeklyCount, extra, dailyDone }
- Symbol.jsx가 localStorage useState 코드 대신 useSymbolStore 사용
- SymbolCard가 charId 기반으로 값 읽기/업데이트
- 닉네임 입력/조회 버튼 높이 정렬 (border-2 → border + box-border)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-15 14:06:01 +09:00
parent 34a8158074
commit 73c024b7a7
4 changed files with 156 additions and 48 deletions

View file

@ -18,7 +18,8 @@
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.14.0" "react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@ -3139,6 +3140,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }

View file

@ -20,7 +20,8 @@
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-router-dom": "^7.14.0" "react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",

View file

@ -3,7 +3,7 @@ import { useQuery, useMutation } 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 Select from '../../components/Select' import Select from '../../components/Select'
import { useSymbolStore } from './store'
const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱'] const TYPE_ORDER = ['아케인', '어센틱', '그랜드 어센틱']
@ -57,12 +57,19 @@ function CharacterCard({ char, active, onSelect, onRemove }) {
) )
} }
function SymbolCard({ symbol, equipped }) { function SymbolCard({ symbol, equipped, charId }) {
const [weeklyCount, setWeeklyCount] = useState(3) const progress = useSymbolStore((s) => s.progress?.[charId]?.[symbol.id])
const [dailyDone, setDailyDone] = useState(false) const updateSymbol = useSymbolStore((s) => s.updateSymbol)
const dailyDone = progress?.dailyDone ?? false
const weeklyCount = progress?.weeklyCount ?? 3
const daily = progress?.daily ?? symbol.daily_default
const extra = progress?.extra ?? 0
const patch = (p) => charId && updateSymbol(charId, symbol.id, p)
// ( ) // ( )
const level = equipped ? 0 : 0 const level = progress?.level ?? 0
const growth = 0 const growth = progress?.growth ?? 0
const requireGrowth = symbol.levels?.[0]?.required_count || 0 const requireGrowth = symbol.levels?.[0]?.required_count || 0
const remainingSymbols = '-' const remainingSymbols = '-'
const remainingMeso = '-' const remainingMeso = '-'
@ -96,7 +103,7 @@ function SymbolCard({ symbol, equipped }) {
<button <button
type="button" type="button"
disabled={!equipped} disabled={!equipped}
onClick={() => setDailyDone((v) => !v)} onClick={() => patch({ dailyDone: !dailyDone })}
title="오늘 일퀘 완료 여부" title="오늘 일퀘 완료 여부"
className={`shrink-0 rounded-md h-8 px-3 text-xs font-semibold border transition disabled:opacity-40 disabled:cursor-not-allowed ${ className={`shrink-0 rounded-md h-8 px-3 text-xs font-semibold border transition disabled:opacity-40 disabled:cursor-not-allowed ${
dailyDone dailyDone
@ -132,7 +139,8 @@ function SymbolCard({ symbol, equipped }) {
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
defaultValue={equipped ? String(symbol.daily_default) : '0'} value={equipped ? String(daily) : '0'}
onChange={(e) => patch({ daily: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!equipped} disabled={!equipped}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition" className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/> />
@ -142,7 +150,7 @@ function SymbolCard({ symbol, equipped }) {
<label className="block text-xs text-gray-400">주간퀘 획득</label> <label className="block text-xs text-gray-400">주간퀘 획득</label>
<Select <Select
value={weeklyCount} value={weeklyCount}
onChange={setWeeklyCount} onChange={(v) => patch({ weeklyCount: v })}
options={[1, 2, 3].map((n) => ({ options={[1, 2, 3].map((n) => ({
value: n, value: n,
label: `${n * symbol.weekly_default}`, label: `${n * symbol.weekly_default}`,
@ -156,7 +164,8 @@ function SymbolCard({ symbol, equipped }) {
<input <input
type="text" type="text"
inputMode="numeric" inputMode="numeric"
defaultValue="0" value={equipped ? String(extra) : '0'}
onChange={(e) => patch({ extra: Number(e.target.value.replace(/[^\d]/g, '')) || 0 })}
disabled={!equipped} disabled={!equipped}
className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition" className="w-full h-10 rounded-md border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 disabled:opacity-50 transition"
/> />
@ -199,8 +208,6 @@ export default function Symbol() {
return () => setFullscreen(false) return () => setFullscreen(false)
}, [setFullscreen]) }, [setFullscreen])
const STORAGE_KEY = 'maple-symbol'
// (DB ) // (DB )
const { data: allSymbols = [] } = useQuery({ const { data: allSymbols = [] } = useQuery({
queryKey: ['symbol', 'symbols'], queryKey: ['symbol', 'symbols'],
@ -222,42 +229,28 @@ export default function Symbol() {
useEffect(() => { useEffect(() => {
if (!tab && tabs.length) setTab(tabs[0].key) if (!tab && tabs.length) setTab(tabs[0].key)
}, [tabs, tab]) }, [tabs, tab])
const [characters, setCharacters] = useState(() => { const characters = useSymbolStore((s) => s.characters)
try { const selectedCharId = useSymbolStore((s) => s.selectedCharId)
const saved = localStorage.getItem(STORAGE_KEY) const addCharacter = useSymbolStore((s) => s.addCharacter)
if (saved) return JSON.parse(saved).characters || [] const removeCharacter = useSymbolStore((s) => s.removeCharacter)
} catch { /* ignore */ } const selectCharacter = useSymbolStore((s) => s.selectCharacter)
return []
})
const [selectedCharId, setSelectedCharId] = useState(() => {
try {
const saved = localStorage.getItem(STORAGE_KEY)
if (saved) return JSON.parse(saved).selectedCharId ?? null
} catch { /* ignore */ }
return null
})
const [addName, setAddName] = useState('') const [addName, setAddName] = useState('')
const [addError, setAddError] = useState('') const [addError, setAddError] = useState('')
useEffect(() => {
localStorage.setItem(STORAGE_KEY, JSON.stringify({ characters, selectedCharId }))
}, [characters, selectedCharId])
const symbols = allSymbols.filter((s) => s.type === tab) const symbols = allSymbols.filter((s) => s.type === tab)
const tabInfo = tabs.find((t) => t.key === tab) const tabInfo = tabs.find((t) => t.key === tab)
const searchMutation = useMutation({ const searchMutation = useMutation({
mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`), mutationFn: (name) => api(`/api/character/search?name=${encodeURIComponent(name)}`),
onSuccess: (data) => { onSuccess: (data) => {
setCharacters((prev) => { if (characters.find((c) => c.character_name === data.character_name)) {
if (prev.find((c) => c.character_name === data.character_name)) { setAddError('이미 추가된 캐릭터입니다')
setAddError('이미 추가된 캐릭터입니다') return
return prev }
} setAddError('')
setAddError('') setAddName('')
setAddName('') addCharacter(data)
setSelectedCharId(data.ocid)
return [...prev, { ...data, id: data.ocid }]
})
}, },
onError: (err) => setAddError(err.message || '조회 실패'), onError: (err) => setAddError(err.message || '조회 실패'),
}) })
@ -290,7 +283,7 @@ export default function Symbol() {
value={addName} value={addName}
onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }} onChange={(e) => { setAddName(e.target.value); if (addError) setAddError('') }}
placeholder="캐릭터 닉네임으로 장착 심볼 불러오기" placeholder="캐릭터 닉네임으로 장착 심볼 불러오기"
className="w-full h-12 rounded-lg border-2 border-white/10 bg-gray-950 pl-10 pr-4 text-base outline-none focus:border-emerald-500/60 hover:border-white/20 transition" className="w-full h-12 box-border rounded-lg border border-white/10 bg-gray-950 pl-10 pr-4 text-base outline-none focus:border-emerald-500/60 hover:border-white/20 transition"
/> />
</div> </div>
<button <button
@ -311,11 +304,8 @@ export default function Symbol() {
key={c.id} key={c.id}
char={c} char={c}
active={c.id === selectedCharId} active={c.id === selectedCharId}
onSelect={() => setSelectedCharId(c.id)} onSelect={() => selectCharacter(c.id)}
onRemove={() => { onRemove={() => removeCharacter(c.id)}
setCharacters((prev) => prev.filter((x) => x.id !== c.id))
if (selectedCharId === c.id) setSelectedCharId(null)
}}
/> />
))} ))}
</div> </div>
@ -348,7 +338,7 @@ export default function Symbol() {
{/* 심볼 카드 그리드 */} {/* 심볼 카드 그리드 */}
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3">
{symbols.map((s, i) => ( {symbols.map((s, i) => (
<SymbolCard key={s.id} symbol={s} equipped={isEquipped(i)} /> <SymbolCard key={s.id} symbol={s} equipped={isEquipped(i)} charId={selectedCharId} />
))} ))}
</div> </div>

View file

@ -0,0 +1,87 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
/**
* 심볼 계산기 상태
* characters: [{ id, character_name, character_level, job_name, character_image, ... }]
* selectedCharId: 현재 선택된 캐릭터 id (ocid)
* progress: {
* [charId]: {
* [symbolId]: {
* level: number,
* growth: number, // 현재 누적 성장치
* daily: number, // 일퀘 획득량 (기본값 수정 가능)
* weeklyCount: 1|2|3, // 주간퀘 횟수
* extra: number, // 추가 심볼
* dailyDone: boolean, // 금일 일퀘 완료 여부
* }
* }
* }
*/
export const useSymbolStore = create(persist(
(set, get) => ({
characters: [],
selectedCharId: null,
progress: {},
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
const entry = { ...char, id: char.ocid }
return {
characters: [...s.characters, entry],
selectedCharId: entry.id,
}
}),
removeCharacter: (id) => set((s) => {
const nextProgress = { ...s.progress }
delete nextProgress[id]
return {
characters: s.characters.filter((c) => c.id !== id),
selectedCharId: s.selectedCharId === id ? null : s.selectedCharId,
progress: nextProgress,
}
}),
selectCharacter: (id) => set({ selectedCharId: id }),
getSymbolState: (charId, symbolId) => get().progress?.[charId]?.[symbolId],
updateSymbol: (charId, symbolId, patch) => set((s) => {
const charProg = s.progress[charId] || {}
const symProg = charProg[symbolId] || {}
return {
progress: {
...s.progress,
[charId]: {
...charProg,
[symbolId]: { ...symProg, ...patch },
},
},
}
}),
resetCharacter: (charId) => set((s) => {
const next = { ...s.progress }
delete next[charId]
return { progress: next }
}),
}),
{
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 || {},
}
},
},
))