diff --git a/frontend/src/features/liberation/pc/Destiny.jsx b/frontend/src/features/liberation/pc/Destiny.jsx
new file mode 100644
index 0000000..faadfb8
--- /dev/null
+++ b/frontend/src/features/liberation/pc/Destiny.jsx
@@ -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 (
+ <>
+ {/* 계산 모드 탭 */}
+
+ {[
+ { key: 'simple', label: '단순 계산' },
+ { key: 'weekly', label: '주차별 계산' },
+ ].map((t) => {
+ const active = calcMode === t.key
+ return (
+
+ )
+ })}
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/features/liberation/pc/Genesis.jsx b/frontend/src/features/liberation/pc/Genesis.jsx
new file mode 100644
index 0000000..6bd3177
--- /dev/null
+++ b/frontend/src/features/liberation/pc/Genesis.jsx
@@ -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 (
+ <>
+ {/* 계산 모드 탭 */}
+
+ {[
+ { key: 'simple', label: '단순 계산' },
+ { key: 'weekly', label: '주차별 계산' },
+ ].map((t) => {
+ const active = calcMode === t.key
+ return (
+
+ )
+ })}
+
+
+
+
+ {/* 현재 진행 상태 입력 */}
+
+
현재 진행 상태
+
+
+
+
+ setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
+ />
+
+
+
+
+ setState((prev) => ({ ...prev, startChapter: idx }))}
+ />
+
+
+
+
+
+
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)' }}
+ />
+
+ / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
+
+
+
+
+
+
+ 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 }))}
+ />
+
+
+
+
+
+ setResetOpen(false)}
+ onConfirm={doReset}
+ title="전체 초기화"
+ description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
+ confirmText="초기화"
+ destructive
+ />
+ >
+ )
+}
diff --git a/frontend/src/features/liberation/pc/Liberation.jsx b/frontend/src/features/liberation/pc/Liberation.jsx
index d696af3..552e234 100644
--- a/frontend/src/features/liberation/pc/Liberation.jsx
+++ b/frontend/src/features/liberation/pc/Liberation.jsx
@@ -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 (
{/* 해방 종류 탭 */}
@@ -144,156 +60,7 @@ export default function Liberation() {
})}
- {liberationType === 'destiny' ? (
-
-
구현 예정
-
데스티니 해방 계산기는 준비 중입니다.
-
- ) : (<>
- {/* 계산 모드 탭 */}
-
- {[
- { key: 'simple', label: '단순 계산' },
- { key: 'weekly', label: '주차별 계산' },
- ].map((t) => {
- const active = calcMode === t.key
- return (
-
- )
- })}
-
-
-
-
- {/* 현재 진행 상태 입력 */}
-
-
현재 진행 상태
-
-
-
-
- setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
- />
-
-
-
-
- setState((prev) => ({ ...prev, startChapter: idx }))}
- />
-
-
-
-
-
-
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)' }}
- />
-
- / {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
-
-
-
-
-
-
- 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 }))}
- />
-
-
-
-
- >)}
-
- setResetOpen(false)}
- onConfirm={doReset}
- title="전체 초기화"
- description={`${calcMode === 'simple' ? '단순 계산' : '주차별 계산'} 모드의 입력을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.\n다른 모드의 값은 유지됩니다.`}
- confirmText="초기화"
- destructive
- />
+ {liberationType === 'genesis' ? : }
)
}
diff --git a/frontend/src/features/liberation/pc/components/ProgressBar.jsx b/frontend/src/features/liberation/pc/components/ProgressBar.jsx
index 801cec2..3685673 100644
--- a/frontend/src/features/liberation/pc/components/ProgressBar.jsx
+++ b/frontend/src/features/liberation/pc/components/ProgressBar.jsx
@@ -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' : ''
}`}>
@@ -101,7 +106,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
·
{completionDate ? formatKoreanDate(completionDate) : 미정}
diff --git a/frontend/src/features/liberation/store.js b/frontend/src/features/liberation/store.js
index b6f3d51..a64c6da 100644
--- a/frontend/src/features/liberation/store.js
+++ b/frontend/src/features/liberation/store.js
@@ -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 }
diff --git a/frontend/src/index.css b/frontend/src/index.css
index 711f605..181b68c 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -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;