심볼 계산기에 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:
parent
34a8158074
commit
73c024b7a7
4 changed files with 156 additions and 48 deletions
32
frontend/package-lock.json
generated
32
frontend/package-lock.json
generated
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
87
frontend/src/features/symbol/store.js
Normal file
87
frontend/src/features/symbol/store.js
Normal 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 || {},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
))
|
||||||
Loading…
Add table
Reference in a new issue