해방 탭 상태 분리 (제네시스/데스티니 각각)
- Liberation.jsx를 탭 shell로 단순화하고 Genesis/Destiny 컴포넌트 분리 - liberationType을 store에 persist해 새로고침/재접속 후에도 마지막 탭 유지 - calcMode를 genesisCalcMode/destinyCalcMode로 분리해 무기별 독립 저장 - ProgressBar를 chapters/imageBase/completionColor prop 받도록 일반화 - Destiny 컴포넌트에 계산 모드 탭 + 진행 바 표시, 완료일 색은 sky blue (--destiny-date: 다크 #38bdf8 / 라이트 #0284c7) - 데스티니 전용 슬롯(destinySimple/destinyWeekly)과 updateDestinySlot/ resetDestinySlot 액션 추가 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
29fcb39eb3
commit
0448b0bfc8
6 changed files with 346 additions and 255 deletions
54
frontend/src/features/liberation/pc/Destiny.jsx
Normal file
54
frontend/src/features/liberation/pc/Destiny.jsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { DESTINY_CHAPTERS, DESTINY_QUEST_IMAGE_BASE } from '../data'
|
||||
import { useLiberationStore } from '../store'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
|
||||
export default function Destiny() {
|
||||
const calcMode = useLiberationStore((s) => s.destinyCalcMode)
|
||||
const setCalcMode = useLiberationStore((s) => s.setDestinyCalcMode)
|
||||
const state = useLiberationStore((s) => s.destinyCalcMode === 'weekly' ? s.destinyWeekly : s.destinySimple)
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 계산 모드 탭 */}
|
||||
<div
|
||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: 'simple', label: '단순 계산' },
|
||||
{ key: 'weekly', label: '주차별 계산' },
|
||||
].map((t) => {
|
||||
const active = calcMode === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCalcMode(t.key)}
|
||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
chapters={DESTINY_CHAPTERS}
|
||||
imageBase={DESTINY_QUEST_IMAGE_BASE}
|
||||
startChapter={state.startChapter}
|
||||
currentPoints={state.currentPoints}
|
||||
completionDate={null}
|
||||
completionColor="var(--destiny-date)"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
234
frontend/src/features/liberation/pc/Genesis.jsx
Normal file
234
frontend/src/features/liberation/pc/Genesis.jsx
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
GENESIS_CHAPTERS,
|
||||
GENESIS_TOTAL,
|
||||
MONTHLY_BOSSES,
|
||||
QUEST_BOSS_IMAGE_BASE,
|
||||
formatDate,
|
||||
} from '../data'
|
||||
import { useLiberationStore } from '../store'
|
||||
import {
|
||||
bossEarn,
|
||||
calcWeekPoints,
|
||||
calcDoneEarn,
|
||||
calcMonthlyEarn,
|
||||
getSchedulerWeekRange,
|
||||
computeCompletionDate,
|
||||
} from '../utils'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
import PointsInput from './components/PointsInput'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
import WeeklyDefault from './components/WeeklyDefault'
|
||||
import DatePicker from '../../../components/common/DatePicker'
|
||||
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||
|
||||
export default function Genesis() {
|
||||
const calcMode = useLiberationStore((s) => s.genesisCalcMode)
|
||||
const state = useLiberationStore((s) => s[s.genesisCalcMode])
|
||||
const setCalcMode = useLiberationStore((s) => s.setGenesisCalcMode)
|
||||
const updateSlot = useLiberationStore((s) => s.updateSlot)
|
||||
const resetSlot = useLiberationStore((s) => s.resetSlot)
|
||||
const setState = (updater) => updateSlot(updater)
|
||||
|
||||
// 포인트 이월: 현재 퀘스트 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||
const priorConsumed = GENESIS_CHAPTERS
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0)
|
||||
let cascadeIdx = state.startChapter
|
||||
let cascadeRemain = state.currentPoints
|
||||
let cascadeConsumed = 0
|
||||
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
|
||||
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeIdx++
|
||||
}
|
||||
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
|
||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||
const weeklyEarn = calcWeekPoints(state.weekly)
|
||||
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
||||
const doneEarn = calcDoneEarn(state.weekly)
|
||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||
|
||||
// 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||
const headerWeekly = calcMode === 'weekly'
|
||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||
: weeklyEarn
|
||||
const headerMonthly = (() => {
|
||||
if (calcMode !== 'weekly') return monthlyEarn
|
||||
const sw = state.schedulerWeeks || []
|
||||
if (!state.startDate) return 0
|
||||
const claimed = {}
|
||||
sw.forEach((w, idx) => {
|
||||
const diff = w.config.blackMage?.difficulty
|
||||
if (!diff || diff === 'none') return
|
||||
const r = getSchedulerWeekRange(state.startDate, idx + 1)
|
||||
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||
for (const m of months) {
|
||||
if (!(m in claimed)) {
|
||||
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
||||
})()
|
||||
|
||||
const completionDate = useMemo(
|
||||
() => computeCompletionDate({
|
||||
calcMode, state, alreadyDone, remaining,
|
||||
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||
}),
|
||||
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
|
||||
)
|
||||
const isDone = completionDate !== null
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const doReset = () => {
|
||||
resetSlot()
|
||||
setResetOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 계산 모드 탭 */}
|
||||
<div
|
||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: 'simple', label: '단순 계산' },
|
||||
{ key: 'weekly', label: '주차별 계산' },
|
||||
].map((t) => {
|
||||
const active = calcMode === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCalcMode(t.key)}
|
||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
chapters={GENESIS_CHAPTERS}
|
||||
imageBase={QUEST_BOSS_IMAGE_BASE}
|
||||
startChapter={state.startChapter}
|
||||
currentPoints={state.currentPoints}
|
||||
completionDate={isDone ? formatDate(completionDate) : null}
|
||||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
||||
<DatePicker
|
||||
value={formatDate(state.startDate)}
|
||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
||||
<div
|
||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
}}
|
||||
>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
||||
style={{ color: 'var(--text-strong)' }}
|
||||
/>
|
||||
<span
|
||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
||||
style={{
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeeklyDefault
|
||||
weekly={state.weekly}
|
||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||
totalWeekly={headerWeekly}
|
||||
totalMonthly={headerMonthly}
|
||||
remaining={remaining}
|
||||
mode={calcMode}
|
||||
startDate={state.startDate}
|
||||
weeks={state.schedulerWeeks}
|
||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
||||
/>
|
||||
|
||||
<div className="max-w-3xl mx-auto flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||
style={{
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
background: 'var(--icon-danger-bg)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
전체 초기화
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={doReset}
|
||||
title="전체 초기화"
|
||||
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
||||
confirmText="초기화"
|
||||
destructive
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,29 +1,10 @@
|
|||
import { useState, useLayoutEffect, useMemo } from 'react'
|
||||
import { useLayoutEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { api } from '../../../api/client'
|
||||
import {
|
||||
GENESIS_CHAPTERS,
|
||||
GENESIS_TOTAL,
|
||||
MONTHLY_BOSSES,
|
||||
formatDate,
|
||||
} from '../data'
|
||||
import { useLiberationStore } from '../store'
|
||||
import {
|
||||
bossEarn,
|
||||
calcWeekPoints,
|
||||
calcDoneEarn,
|
||||
calcMonthlyEarn,
|
||||
getSchedulerWeekRange,
|
||||
computeCompletionDate,
|
||||
} from '../utils'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
import PointsInput from './components/PointsInput'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
import WeeklyDefault from './components/WeeklyDefault'
|
||||
import DatePicker from '../../../components/common/DatePicker'
|
||||
import ConfirmDialog from '../../../components/common/ConfirmDialog'
|
||||
import { useLayout } from '../../../components/pc/Layout'
|
||||
import { useLiberationStore } from '../store'
|
||||
import Genesis from './Genesis'
|
||||
import Destiny from './Destiny'
|
||||
|
||||
export default function Liberation() {
|
||||
const { setFullscreen } = useLayout()
|
||||
|
|
@ -32,7 +13,8 @@ export default function Liberation() {
|
|||
return () => setFullscreen(false)
|
||||
}, [setFullscreen])
|
||||
|
||||
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
||||
const liberationType = useLiberationStore((s) => s.liberationType)
|
||||
const setLiberationType = useLiberationStore((s) => s.setLiberationType)
|
||||
|
||||
const genesisImg = useQuery({
|
||||
queryKey: ['image', '제네시스 스태프'],
|
||||
|
|
@ -45,72 +27,6 @@ export default function Liberation() {
|
|||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
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
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0)
|
||||
let cascadeIdx = state.startChapter
|
||||
let cascadeRemain = state.currentPoints
|
||||
let cascadeConsumed = 0
|
||||
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
|
||||
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeIdx++
|
||||
}
|
||||
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
|
||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||
const weeklyEarn = calcWeekPoints(state.weekly)
|
||||
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
||||
const doneEarn = calcDoneEarn(state.weekly)
|
||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||
|
||||
// 주차별 모드 헤더 합산 (검은 마법사는 월별 슬롯 1회만 카운트)
|
||||
const headerWeekly = calcMode === 'weekly'
|
||||
? (state.schedulerWeeks || []).reduce((s, w) => s + calcWeekPoints(w.config), 0)
|
||||
: weeklyEarn
|
||||
const headerMonthly = (() => {
|
||||
if (calcMode !== 'weekly') return monthlyEarn
|
||||
const sw = state.schedulerWeeks || []
|
||||
if (!state.startDate) return 0
|
||||
const claimed = {}
|
||||
sw.forEach((w, idx) => {
|
||||
const diff = w.config.blackMage?.difficulty
|
||||
if (!diff || diff === 'none') return
|
||||
const r = getSchedulerWeekRange(state.startDate, idx + 1)
|
||||
const months = [r.start.format('YYYY-MM'), r.end.format('YYYY-MM')]
|
||||
for (const m of months) {
|
||||
if (!(m in claimed)) {
|
||||
claimed[m] = bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return Object.values(claimed).reduce((s, v) => s + v, 0)
|
||||
})()
|
||||
|
||||
const completionDate = useMemo(
|
||||
() => computeCompletionDate({
|
||||
calcMode, state, alreadyDone, remaining,
|
||||
weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth,
|
||||
}),
|
||||
[calcMode, state, alreadyDone, remaining, weeklyEarn, doneEarn, monthlyEarn, monthlyDoneThisMonth],
|
||||
)
|
||||
const isDone = completionDate !== null
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const doReset = () => {
|
||||
resetSlot()
|
||||
setResetOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 pb-10">
|
||||
{/* 해방 종류 탭 */}
|
||||
|
|
@ -144,156 +60,7 @@ export default function Liberation() {
|
|||
})}
|
||||
</div>
|
||||
|
||||
{liberationType === 'destiny' ? (
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 220px)',
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
|
||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
||||
</div>
|
||||
) : (<>
|
||||
{/* 계산 모드 탭 */}
|
||||
<div
|
||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: 'simple', label: '단순 계산' },
|
||||
{ key: 'weekly', label: '주차별 계산' },
|
||||
].map((t) => {
|
||||
const active = calcMode === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCalcMode(t.key)}
|
||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
startChapter={state.startChapter}
|
||||
currentPoints={state.currentPoints}
|
||||
completionDate={isDone ? formatDate(completionDate) : null}
|
||||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
||||
<DatePicker
|
||||
value={formatDate(state.startDate)}
|
||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
||||
<div
|
||||
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: 'var(--input-border)',
|
||||
}}
|
||||
>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
||||
style={{ color: 'var(--text-strong)' }}
|
||||
/>
|
||||
<span
|
||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
||||
style={{
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeeklyDefault
|
||||
weekly={state.weekly}
|
||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||
totalWeekly={headerWeekly}
|
||||
totalMonthly={headerMonthly}
|
||||
remaining={remaining}
|
||||
mode={calcMode}
|
||||
startDate={state.startDate}
|
||||
weeks={state.schedulerWeeks}
|
||||
onChangeWeeks={(w) => setState((prev) => ({ ...prev, schedulerWeeks: w }))}
|
||||
/>
|
||||
|
||||
<div className="max-w-3xl mx-auto flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||
style={{
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
background: 'var(--icon-danger-bg)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
전체 초기화
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={doReset}
|
||||
title="전체 초기화"
|
||||
description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
|
||||
confirmText="초기화"
|
||||
destructive
|
||||
/>
|
||||
{liberationType === 'genesis' ? <Genesis /> : <Destiny />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
|
||||
|
||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||
function formatKoreanDate(s) {
|
||||
const [y, m, d] = s.split('-')
|
||||
|
|
@ -7,8 +5,15 @@ function formatKoreanDate(s) {
|
|||
return `${y}년 ${m}월 ${d}일 (${dow})`
|
||||
}
|
||||
|
||||
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
||||
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
||||
export default function ProgressBar({
|
||||
chapters,
|
||||
imageBase,
|
||||
startChapter,
|
||||
currentPoints,
|
||||
completionDate,
|
||||
completionColor = 'var(--warning-text-bright)',
|
||||
}) {
|
||||
const chapterStates = chapters.map((c) => {
|
||||
if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required }
|
||||
if (c.idx === startChapter) {
|
||||
const filled = Math.min(currentPoints, c.required)
|
||||
|
|
@ -41,7 +46,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
status === 'pending' ? 'opacity-50' : ''
|
||||
}`}>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
||||
src={`${imageBase}/${chapter.boss}.webp`}
|
||||
alt={chapter.boss}
|
||||
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
||||
/>
|
||||
|
|
@ -101,7 +106,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
<span style={{ color: 'var(--text-dim)' }}>·</span>
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
style={{ color: completionColor }}
|
||||
>
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -24,28 +24,55 @@ function makeInitialSlot() {
|
|||
}
|
||||
}
|
||||
|
||||
function makeInitialDestinySlot() {
|
||||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 해방 계산기 상태
|
||||
* calcMode: 'simple' | 'weekly'
|
||||
* simple / weekly: 각 모드 독립 슬롯
|
||||
* calcMode: 'simple' | 'weekly' (제네시스/데스티니가 공유)
|
||||
* simple / weekly: 제네시스 모드별 독립 슬롯
|
||||
* destinySimple / destinyWeekly: 데스티니 모드별 독립 슬롯
|
||||
*/
|
||||
export const useLiberationStore = create(persist(
|
||||
(set) => ({
|
||||
calcMode: 'simple',
|
||||
liberationType: 'genesis', // 'genesis' | 'destiny'
|
||||
genesisCalcMode: 'simple',
|
||||
destinyCalcMode: 'simple',
|
||||
simple: makeInitialSlot(),
|
||||
weekly: makeInitialSlot(),
|
||||
destinySimple: makeInitialDestinySlot(),
|
||||
destinyWeekly: makeInitialDestinySlot(),
|
||||
|
||||
setCalcMode: (mode) => set({ calcMode: mode }),
|
||||
setLiberationType: (type) => set({ liberationType: type }),
|
||||
setGenesisCalcMode: (mode) => set({ genesisCalcMode: mode }),
|
||||
setDestinyCalcMode: (mode) => set({ destinyCalcMode: mode }),
|
||||
|
||||
updateSlot: (patch) => set((s) => ({
|
||||
[s.calcMode]: typeof patch === 'function'
|
||||
? patch(s[s.calcMode])
|
||||
: { ...s[s.calcMode], ...patch },
|
||||
[s.genesisCalcMode]: typeof patch === 'function'
|
||||
? patch(s[s.genesisCalcMode])
|
||||
: { ...s[s.genesisCalcMode], ...patch },
|
||||
})),
|
||||
|
||||
resetSlot: () => set((s) => ({ [s.calcMode]: makeInitialSlot() })),
|
||||
resetSlot: () => set((s) => ({ [s.genesisCalcMode]: makeInitialSlot() })),
|
||||
|
||||
updateDestinySlot: (patch) => set((s) => {
|
||||
const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple'
|
||||
return {
|
||||
[key]: typeof patch === 'function' ? patch(s[key]) : { ...s[key], ...patch },
|
||||
}
|
||||
}),
|
||||
|
||||
resetDestinySlot: () => set((s) => {
|
||||
const key = s.destinyCalcMode === 'weekly' ? 'destinyWeekly' : 'destinySimple'
|
||||
return { [key]: makeInitialDestinySlot() }
|
||||
}),
|
||||
}),
|
||||
{ name: 'maple-liberation' },
|
||||
))
|
||||
|
||||
export { makeEmptyWeekly, makeInitialSlot }
|
||||
export { makeEmptyWeekly, makeInitialSlot, makeInitialDestinySlot }
|
||||
|
|
|
|||
|
|
@ -105,6 +105,8 @@
|
|||
--warning-text-bright: #fcd34d;
|
||||
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
||||
|
||||
--destiny-date: #38bdf8;
|
||||
|
||||
--progress-track: #0f172a;
|
||||
--progress-emerald: #10b981;
|
||||
--progress-amber: #f59e0b;
|
||||
|
|
@ -256,6 +258,8 @@
|
|||
--warning-text-bright: #ea580c;
|
||||
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
||||
|
||||
--destiny-date: #0284c7;
|
||||
|
||||
--progress-track: #e5e7eb;
|
||||
--progress-emerald: #10b981;
|
||||
--progress-amber: #f59e0b;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue