해방 탭 상태 분리 (제네시스/데스티니 각각)
- 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 { useQuery } from '@tanstack/react-query'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import { api } from '../../../api/client'
|
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 { useLayout } from '../../../components/pc/Layout'
|
||||||
|
import { useLiberationStore } from '../store'
|
||||||
|
import Genesis from './Genesis'
|
||||||
|
import Destiny from './Destiny'
|
||||||
|
|
||||||
export default function Liberation() {
|
export default function Liberation() {
|
||||||
const { setFullscreen } = useLayout()
|
const { setFullscreen } = useLayout()
|
||||||
|
|
@ -32,7 +13,8 @@ export default function Liberation() {
|
||||||
return () => setFullscreen(false)
|
return () => setFullscreen(false)
|
||||||
}, [setFullscreen])
|
}, [setFullscreen])
|
||||||
|
|
||||||
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
const liberationType = useLiberationStore((s) => s.liberationType)
|
||||||
|
const setLiberationType = useLiberationStore((s) => s.setLiberationType)
|
||||||
|
|
||||||
const genesisImg = useQuery({
|
const genesisImg = useQuery({
|
||||||
queryKey: ['image', '제네시스 스태프'],
|
queryKey: ['image', '제네시스 스태프'],
|
||||||
|
|
@ -45,72 +27,6 @@ export default function Liberation() {
|
||||||
staleTime: Infinity,
|
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 (
|
return (
|
||||||
<div className="space-y-6 pb-10">
|
<div className="space-y-6 pb-10">
|
||||||
{/* 해방 종류 탭 */}
|
{/* 해방 종류 탭 */}
|
||||||
|
|
@ -144,156 +60,7 @@ export default function Liberation() {
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{liberationType === 'destiny' ? (
|
{liberationType === 'genesis' ? <Genesis /> : <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
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../../data'
|
|
||||||
|
|
||||||
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
const DOW = ['일', '월', '화', '수', '목', '금', '토']
|
||||||
function formatKoreanDate(s) {
|
function formatKoreanDate(s) {
|
||||||
const [y, m, d] = s.split('-')
|
const [y, m, d] = s.split('-')
|
||||||
|
|
@ -7,8 +5,15 @@ function formatKoreanDate(s) {
|
||||||
return `${y}년 ${m}월 ${d}일 (${dow})`
|
return `${y}년 ${m}월 ${d}일 (${dow})`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProgressBar({ startChapter, currentPoints, completionDate }) {
|
export default function ProgressBar({
|
||||||
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
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) return { chapter: c, status: 'done', current: c.required }
|
||||||
if (c.idx === startChapter) {
|
if (c.idx === startChapter) {
|
||||||
const filled = Math.min(currentPoints, c.required)
|
const filled = Math.min(currentPoints, c.required)
|
||||||
|
|
@ -41,7 +46,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
||||||
status === 'pending' ? 'opacity-50' : ''
|
status === 'pending' ? 'opacity-50' : ''
|
||||||
}`}>
|
}`}>
|
||||||
<img
|
<img
|
||||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
src={`${imageBase}/${chapter.boss}.webp`}
|
||||||
alt={chapter.boss}
|
alt={chapter.boss}
|
||||||
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
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 style={{ color: 'var(--text-dim)' }}>·</span>
|
||||||
<span
|
<span
|
||||||
className="text-xl font-bold tabular-nums"
|
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>}
|
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -24,28 +24,55 @@ function makeInitialSlot() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeInitialDestinySlot() {
|
||||||
|
return {
|
||||||
|
startChapter: 0,
|
||||||
|
currentPoints: 0,
|
||||||
|
startDate: dayjs(todayKST()).toISOString(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 해방 계산기 상태
|
* 해방 계산기 상태
|
||||||
* calcMode: 'simple' | 'weekly'
|
* calcMode: 'simple' | 'weekly' (제네시스/데스티니가 공유)
|
||||||
* simple / weekly: 각 모드 독립 슬롯
|
* simple / weekly: 제네시스 모드별 독립 슬롯
|
||||||
|
* destinySimple / destinyWeekly: 데스티니 모드별 독립 슬롯
|
||||||
*/
|
*/
|
||||||
export const useLiberationStore = create(persist(
|
export const useLiberationStore = create(persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
calcMode: 'simple',
|
liberationType: 'genesis', // 'genesis' | 'destiny'
|
||||||
|
genesisCalcMode: 'simple',
|
||||||
|
destinyCalcMode: 'simple',
|
||||||
simple: makeInitialSlot(),
|
simple: makeInitialSlot(),
|
||||||
weekly: 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) => ({
|
updateSlot: (patch) => set((s) => ({
|
||||||
[s.calcMode]: typeof patch === 'function'
|
[s.genesisCalcMode]: typeof patch === 'function'
|
||||||
? patch(s[s.calcMode])
|
? patch(s[s.genesisCalcMode])
|
||||||
: { ...s[s.calcMode], ...patch },
|
: { ...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' },
|
{ name: 'maple-liberation' },
|
||||||
))
|
))
|
||||||
|
|
||||||
export { makeEmptyWeekly, makeInitialSlot }
|
export { makeEmptyWeekly, makeInitialSlot, makeInitialDestinySlot }
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,8 @@
|
||||||
--warning-text-bright: #fcd34d;
|
--warning-text-bright: #fcd34d;
|
||||||
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
--warning-text-dim: rgba(252, 211, 77, 0.4);
|
||||||
|
|
||||||
|
--destiny-date: #38bdf8;
|
||||||
|
|
||||||
--progress-track: #0f172a;
|
--progress-track: #0f172a;
|
||||||
--progress-emerald: #10b981;
|
--progress-emerald: #10b981;
|
||||||
--progress-amber: #f59e0b;
|
--progress-amber: #f59e0b;
|
||||||
|
|
@ -256,6 +258,8 @@
|
||||||
--warning-text-bright: #ea580c;
|
--warning-text-bright: #ea580c;
|
||||||
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
--warning-text-dim: rgba(234, 88, 12, 0.4);
|
||||||
|
|
||||||
|
--destiny-date: #0284c7;
|
||||||
|
|
||||||
--progress-track: #e5e7eb;
|
--progress-track: #e5e7eb;
|
||||||
--progress-emerald: #10b981;
|
--progress-emerald: #10b981;
|
||||||
--progress-amber: #f59e0b;
|
--progress-amber: #f59e0b;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue