diff --git a/frontend/src/components/DatePicker.jsx b/frontend/src/components/DatePicker.jsx
new file mode 100644
index 0000000..7afceb4
--- /dev/null
+++ b/frontend/src/components/DatePicker.jsx
@@ -0,0 +1,237 @@
+import { useState, useEffect, useRef } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+
+function ChevronIcon({ dir = 'down', size = 16, className = '' }) {
+ const rotate = { left: 90, right: -90, up: 180, down: 0 }[dir] || 0
+ return (
+
+ )
+}
+
+/**
+ * 다크 테마 커스텀 DatePicker
+ * @param {string} value - "YYYY-MM-DD"
+ * @param {function} onChange
+ * @param {number} minYear
+ */
+export default function DatePicker({ value, onChange, placeholder = '날짜 선택', minYear = 2020 }) {
+ const [isOpen, setIsOpen] = useState(false)
+ const [viewMode, setViewMode] = useState('days')
+ const [viewDate, setViewDate] = useState(() => (value ? new Date(value) : new Date()))
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const handler = (e) => {
+ if (ref.current && !ref.current.contains(e.target)) {
+ setIsOpen(false)
+ setViewMode('days')
+ }
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [])
+
+ useEffect(() => { if (value) setViewDate(new Date(value)) }, [value])
+
+ const year = viewDate.getFullYear()
+ const month = viewDate.getMonth()
+ const firstDay = new Date(year, month, 1).getDay()
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
+
+ const days = []
+ for (let i = 0; i < firstDay; i++) days.push(null)
+ for (let i = 1; i <= daysInMonth; i++) days.push(i)
+
+ const groupIndex = Math.floor((year - minYear) / 12)
+ const startYear = minYear + groupIndex * 12
+ const years = Array.from({ length: 12 }, (_, i) => startYear + i)
+ const canGoPrevYearRange = startYear > minYear
+
+ const stop = (e, cb) => { e.preventDefault(); e.stopPropagation(); cb() }
+
+ const prevMonth = () => {
+ const d = new Date(year, month - 1, 1)
+ if (d.getFullYear() >= minYear) setViewDate(d)
+ }
+ const nextMonth = () => setViewDate(new Date(year, month + 1, 1))
+ const prevYearRange = () => canGoPrevYearRange && setViewDate(new Date(startYear - 12, month, 1))
+ const nextYearRange = () => setViewDate(new Date(startYear + 12, month, 1))
+
+ const selectDate = (day) => {
+ const s = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
+ onChange(s)
+ setIsOpen(false)
+ setViewMode('days')
+ }
+
+ const selectYear = (y) => setViewDate(new Date(y, month, 1))
+ const selectMonth = (m) => { setViewDate(new Date(year, m, 1)); setViewMode('days') }
+
+ const formatDisplay = (s) => {
+ if (!s) return ''
+ const [y, m, d] = s.split('-')
+ return `${y}년 ${parseInt(m)}월 ${parseInt(d)}일`
+ }
+
+ const isSelected = (day) => {
+ if (!value || !day) return false
+ const [y, m, d] = value.split('-')
+ return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day
+ }
+ const isToday = (day) => {
+ if (!day) return false
+ const t = new Date()
+ return t.getFullYear() === year && t.getMonth() === month && t.getDate() === day
+ }
+
+ const currentYear = new Date().getFullYear()
+ const currentMonth = new Date().getMonth()
+ const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
+
+ return (
+
+
+
+
+ {isOpen && (
+
+
+
+
+
+
+
+
+ {viewMode === 'years' ? (
+
+ 연도
+
+ {years.map((y) => (
+
+ ))}
+
+ 월
+
+ {monthNames.map((m, i) => (
+
+ ))}
+
+
+ ) : (
+
+
+ {['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
+
+ {d}
+
+ ))}
+
+
+ {days.map((day, i) => {
+ const dw = i % 7
+ const selected = isSelected(day)
+ const today = isToday(day)
+ return (
+
+ )
+ })}
+
+
+ )}
+
+
+ )}
+
+
+ )
+}
diff --git a/frontend/src/features/liberation/Liberation.jsx b/frontend/src/features/liberation/Liberation.jsx
new file mode 100644
index 0000000..7f5c7d3
--- /dev/null
+++ b/frontend/src/features/liberation/Liberation.jsx
@@ -0,0 +1,171 @@
+import { useState, useMemo, useEffect } from 'react'
+import {
+ GENESIS_CHAPTERS,
+ GENESIS_TOTAL,
+ WEEKLY_BOSSES,
+ MONTHLY_BOSSES,
+ calcPoints,
+ addWeeks,
+ formatDate,
+} from './data'
+import WeekCard from './components/WeekCard'
+import QuestSelector from './components/QuestSelector'
+import PointsInput from './components/PointsInput'
+import ProgressBar from './components/ProgressBar'
+import DatePicker from '../../components/DatePicker'
+
+const STORAGE_KEY = 'maple-liberation'
+
+function makeEmptyWeek(startDate) {
+ const bosses = {}
+ WEEKLY_BOSSES.forEach((b) => {
+ bosses[b.key] = {
+ enabled: false,
+ difficulty: b.difficulties[0].key,
+ party: 1,
+ }
+ })
+ return {
+ startDate: startDate.toISOString(),
+ bosses,
+ blackMage: {
+ enabled: false,
+ difficulty: MONTHLY_BOSSES[0].difficulties[0].key,
+ party: 1,
+ },
+ }
+}
+
+function calcWeekPoints(weekData) {
+ let points = 0
+ WEEKLY_BOSSES.forEach((b) => {
+ const sel = weekData.bosses[b.key]
+ if (!sel?.enabled) return
+ const diff = b.difficulties.find((d) => d.key === sel.difficulty)
+ if (!diff) return
+ points += calcPoints(diff.points, sel.party)
+ })
+ if (weekData.blackMage?.enabled) {
+ const bm = MONTHLY_BOSSES[0]
+ const diff = bm.difficulties.find((d) => d.key === weekData.blackMage.difficulty)
+ if (diff) points += calcPoints(diff.points, weekData.blackMage.party)
+ }
+ return points
+}
+
+export default function Liberation() {
+ const [state, setState] = useState(() => {
+ const saved = localStorage.getItem(STORAGE_KEY)
+ if (saved) {
+ try { return JSON.parse(saved) } catch { /* ignore */ }
+ }
+ return {
+ startChapter: 0,
+ currentPoints: 0,
+ weeks: [makeEmptyWeek(new Date())],
+ }
+ })
+
+ useEffect(() => {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(state))
+ }, [state])
+
+ // 주차별 계산
+ const progressByWeek = useMemo(() => {
+ const result = []
+ const startConsumedBefore = GENESIS_CHAPTERS
+ .slice(0, state.startChapter)
+ .reduce((s, c) => s + c.required, 0)
+ let totalAccumulated = startConsumedBefore + state.currentPoints
+
+ for (const week of state.weeks) {
+ const earned = calcWeekPoints(week)
+ totalAccumulated += earned
+
+ let temp = totalAccumulated
+ let chapterIdx = 0
+ while (chapterIdx < GENESIS_CHAPTERS.length && temp >= GENESIS_CHAPTERS[chapterIdx].required) {
+ temp -= GENESIS_CHAPTERS[chapterIdx].required
+ chapterIdx++
+ }
+
+ const isCompleted = totalAccumulated >= GENESIS_TOTAL
+ const chapterInfo = isCompleted
+ ? { name: '완료', current: GENESIS_TOTAL, required: GENESIS_TOTAL }
+ : {
+ name: GENESIS_CHAPTERS[chapterIdx]?.boss || '',
+ current: temp,
+ required: GENESIS_CHAPTERS[chapterIdx]?.required || 0,
+ }
+
+ result.push({
+ points: earned,
+ cumulative: totalAccumulated,
+ completed: isCompleted,
+ chapterInfo,
+ })
+ }
+ return result
+ }, [state])
+
+ const completedWeekIdx = progressByWeek.findIndex((w) => w.completed)
+ const isDone = completedWeekIdx >= 0
+ const completionDate = isDone
+ ? addWeeks(new Date(state.weeks[completedWeekIdx].startDate), 1)
+ : null
+
+ const updateWeek = (idx, newWeekData) => {
+ setState((prev) => ({
+ ...prev,
+ weeks: prev.weeks.map((w, i) => (i === idx ? newWeekData : w)),
+ }))
+ }
+
+ const addWeek = () => {
+ setState((prev) => {
+ const lastWeek = prev.weeks[prev.weeks.length - 1]
+ const nextStart = addWeeks(new Date(lastWeek.startDate), 1)
+ return {
+ ...prev,
+ weeks: [...prev.weeks, { ...lastWeek, startDate: nextStart.toISOString() }],
+ }
+ })
+ }
+
+ const removeWeek = (idx) => {
+ setState((prev) => ({ ...prev, weeks: prev.weeks.filter((_, i) => i !== idx) }))
+ }
+
+ const resetAll = () => {
+ if (!confirm('입력한 내용을 모두 초기화하시겠습니까?')) return
+ setState({
+ startChapter: 0,
+ currentPoints: 0,
+ weeks: [makeEmptyWeek(new Date())],
+ })
+ }
+
+ const setFirstWeekDate = (dateStr) => {
+ const d = new Date(dateStr)
+ setState((prev) => {
+ const weeks = prev.weeks.map((w, i) => ({
+ ...w,
+ startDate: addWeeks(d, i).toISOString(),
+ }))
+ return { ...prev, weeks }
+ })
+ }
+
+ const totalCumulative = progressByWeek[progressByWeek.length - 1]?.cumulative
+ || (GENESIS_CHAPTERS.slice(0, state.startChapter).reduce((s, c) => s + c.required, 0) + state.currentPoints)
+ const overallProgress = Math.min((totalCumulative / GENESIS_TOTAL) * 100, 100)
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/liberation/components/PointsInput.jsx b/frontend/src/features/liberation/components/PointsInput.jsx
new file mode 100644
index 0000000..144ac84
--- /dev/null
+++ b/frontend/src/features/liberation/components/PointsInput.jsx
@@ -0,0 +1,45 @@
+import { useState, useEffect } from 'react'
+
+/**
+ * 포인트 입력 (3자리 쉼표, 최대 4자리, 0이면 포커스 시 지움)
+ */
+export default function PointsInput({ value, onChange, max = 9999, className = '', ...rest }) {
+ const [text, setText] = useState(() => (value > 0 ? value.toLocaleString() : '0'))
+
+ useEffect(() => {
+ setText(value > 0 ? value.toLocaleString() : '0')
+ }, [value])
+
+ const handleChange = (e) => {
+ let digits = e.target.value.replace(/[^\d]/g, '')
+ if (digits.length > String(max).length) digits = digits.slice(0, String(max).length)
+ const n = digits ? Math.min(Number(digits), max) : 0
+ setText(n > 0 ? n.toLocaleString() : digits === '' ? '' : '0')
+ onChange(n)
+ }
+
+ const handleFocus = (e) => {
+ // 0인 상태에서 포커스되면 지움
+ if (value === 0 || text === '0') {
+ setText('')
+ }
+ e.target.select()
+ }
+
+ const handleBlur = () => {
+ if (text === '' || text === '0') setText('0')
+ }
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/liberation/components/ProgressBar.jsx b/frontend/src/features/liberation/components/ProgressBar.jsx
new file mode 100644
index 0000000..3a8d6a0
--- /dev/null
+++ b/frontend/src/features/liberation/components/ProgressBar.jsx
@@ -0,0 +1,98 @@
+import { GENESIS_CHAPTERS, GENESIS_TOTAL, QUEST_BOSS_IMAGE_BASE } from '../data'
+
+function formatKoreanDate(s) {
+ const [y, m, d] = s.split('-')
+ return `${y}년 ${m}월 ${d}일`
+}
+
+export default function ProgressBar({ totalAccumulated, completionDate }) {
+ const chapterStates = []
+ let remaining = totalAccumulated
+ for (const c of GENESIS_CHAPTERS) {
+ if (remaining >= c.required) {
+ chapterStates.push({ chapter: c, status: 'done', current: c.required })
+ remaining -= c.required
+ } else if (remaining > 0) {
+ chapterStates.push({ chapter: c, status: 'active', current: remaining })
+ remaining = 0
+ } else {
+ chapterStates.push({ chapter: c, status: 'pending', current: 0 })
+ }
+ }
+
+ const renderSegment = ({ chapter, status, current }) => {
+ const pct = (current / chapter.required) * 100
+ return (
+
+ )
+ }
+
+ const renderPortrait = ({ chapter, status }) => (
+
+
+

+
+
+ {chapter.boss}
+
+
+ )
+
+ return (
+
+ {/* 섹션 제목 */}
+
퀘스트 진행 상황
+
+ {/* 1차 / 2차 라벨 */}
+
+
+ {/* 세그먼트 바 (붙어있음) */}
+
+ {chapterStates.map(renderSegment)}
+
+
+ {/* 초상화 (붙어있음) */}
+
+ {chapterStates.map(renderPortrait)}
+
+
+ {/* 예상 해방 날짜 */}
+
+ 예상 해방 날짜
+ ·
+
+ {completionDate ? formatKoreanDate(completionDate) : 미정}
+
+
+
+ )
+}
diff --git a/frontend/src/features/liberation/components/QuestSelector.jsx b/frontend/src/features/liberation/components/QuestSelector.jsx
new file mode 100644
index 0000000..b5775f0
--- /dev/null
+++ b/frontend/src/features/liberation/components/QuestSelector.jsx
@@ -0,0 +1,70 @@
+import { useState, useEffect, useRef } from 'react'
+import { GENESIS_CHAPTERS, QUEST_BTBOSS_IMAGE_BASE } from '../data'
+
+/**
+ * 진행 중인 퀘스트 드롭다운
+ * - 선택된 옵션과 옵션 리스트 모두 btboss 이미지로 표시
+ */
+export default function QuestSelector({ value, onChange }) {
+ const [open, setOpen] = useState(false)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ if (!open) return
+ const handler = (e) => {
+ if (!ref.current?.contains(e.target)) setOpen(false)
+ }
+ document.addEventListener('mousedown', handler)
+ return () => document.removeEventListener('mousedown', handler)
+ }, [open])
+
+ const selected = GENESIS_CHAPTERS[value]
+
+ return (
+
+
+
+ {open && (
+
+ {GENESIS_CHAPTERS.map((chapter) => {
+ const isSelected = chapter.idx === value
+ return (
+
+ )
+ })}
+
+ )}
+
+ )
+}
diff --git a/frontend/src/features/liberation/components/WeekCard.jsx b/frontend/src/features/liberation/components/WeekCard.jsx
new file mode 100644
index 0000000..9bd1259
--- /dev/null
+++ b/frontend/src/features/liberation/components/WeekCard.jsx
@@ -0,0 +1,134 @@
+import Select from '../../../components/Select'
+import Checkbox from '../../../components/Checkbox'
+import Tooltip from '../../../components/Tooltip'
+import { WEEKLY_BOSSES, MONTHLY_BOSSES, BOSS_IMAGE_BASE, calcPoints, formatDate } from '../data'
+
+const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
+
+/**
+ * week: { startDate, bosses: { [bossKey]: { enabled, difficulty, party } }, includeBlackMage: {enabled, difficulty, party} }
+ */
+export default function WeekCard({ weekNumber, weekData, cumulativePoints, currentChapter, chapterInfo, onChange, weekProgress }) {
+ const totalThisWeek = weekProgress.points
+ const updateBoss = (bossKey, patch) => {
+ const nextBosses = { ...weekData.bosses, [bossKey]: { ...weekData.bosses[bossKey], ...patch } }
+ onChange({ ...weekData, bosses: nextBosses })
+ }
+
+ const updateBlackMage = (patch) => {
+ onChange({ ...weekData, blackMage: { ...weekData.blackMage, ...patch } })
+ }
+
+ return (
+
+ {/* 헤더: 주차 번호 + 날짜 + 이번 주 획득 + 누적 */}
+
+
+
{weekNumber}주차
+
{formatDate(weekData.startDate)}
+
+
+
+ 획득 +{totalThisWeek}
+
+
+ 누적 {cumulativePoints}
+
+ {chapterInfo && (
+
+ {chapterInfo.name} {chapterInfo.current}/{chapterInfo.required}
+
+ )}
+
+
+
+ {/* 보스 그리드 */}
+
+ {WEEKLY_BOSSES.map((boss) => {
+ const sel = weekData.bosses[boss.key] || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
+ const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
+ const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
+
+ return (
+
+
+
updateBoss(boss.key, { enabled: v })}
+ size="sm"
+ />
+
+
+
+ {boss.name}
+ {earned > 0 && (
+ +{earned}
+ )}
+
+ {sel.enabled && (
+
+
+ )}
+
+ )
+ })}
+
+ {/* 검은 마법사 (월 1회) */}
+ {MONTHLY_BOSSES.map((boss) => {
+ const sel = weekData.blackMage || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
+ const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
+ const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
+
+ return (
+
+
+
updateBlackMage({ enabled: v })}
+ size="sm"
+ />
+
+
+
+ {boss.name} 월간
+ {earned > 0 && (
+ +{earned}
+ )}
+
+ {sel.enabled && (
+
+ updateBlackMage({ difficulty: v })}
+ options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
+ className="flex-1"
+ />
+ updateBlackMage({ party: v })}
+ options={PARTY_OPTIONS}
+ className="w-16"
+ />
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/frontend/src/features/liberation/data.js b/frontend/src/features/liberation/data.js
new file mode 100644
index 0000000..5f25f60
--- /dev/null
+++ b/frontend/src/features/liberation/data.js
@@ -0,0 +1,120 @@
+// 제네시스 해방 챕터 (8단계, 총 6,500)
+// phase 1: 반레온/아카이럼/매그너스/스우 (1차 해방)
+// phase 2: 데미안/윌/루시드/진힐라 (2차 해방)
+export const GENESIS_CHAPTERS = [
+ { idx: 0, phase: 1, boss: '반 레온', required: 500 },
+ { idx: 1, phase: 1, boss: '아카이럼', required: 500 },
+ { idx: 2, phase: 1, boss: '매그너스', required: 500 },
+ { idx: 3, phase: 1, boss: '스우', required: 1000 },
+ { idx: 4, phase: 2, boss: '데미안', required: 1000 },
+ { idx: 5, phase: 2, boss: '윌', required: 1000 },
+ { idx: 6, phase: 2, boss: '루시드', required: 1000 },
+ { idx: 7, phase: 2, boss: '진 힐라', required: 1000 },
+]
+
+// 퀘스트 이미지 경로 (제네시스)
+export const QUEST_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/boss'
+export const QUEST_BTBOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/quest/btboss'
+
+export const GENESIS_TOTAL = GENESIS_CHAPTERS.reduce((s, c) => s + c.required, 0) // 6500
+
+// 주간 보스 (주 1회)
+export const WEEKLY_BOSSES = [
+ {
+ key: 'lotus', name: '스우', image: '스우.webp',
+ difficulties: [
+ { key: 'normal', label: '노말', points: 10 },
+ { key: 'hard', label: '하드', points: 50 },
+ { key: 'extreme', label: '익스트림', points: 50 },
+ ],
+ },
+ {
+ key: 'damien', name: '데미안', image: '데미안.webp',
+ difficulties: [
+ { key: 'normal', label: '노말', points: 10 },
+ { key: 'hard', label: '하드', points: 50 },
+ ],
+ },
+ {
+ key: 'lucid', name: '루시드', image: '루시드.webp',
+ difficulties: [
+ { key: 'easy', label: '이지', points: 15 },
+ { key: 'normal', label: '노말', points: 20 },
+ { key: 'hard', label: '하드', points: 65 },
+ ],
+ },
+ {
+ key: 'will', name: '윌', image: '윌.webp',
+ difficulties: [
+ { key: 'easy', label: '이지', points: 15 },
+ { key: 'normal', label: '노말', points: 25 },
+ { key: 'hard', label: '하드', points: 75 },
+ ],
+ },
+ {
+ key: 'dusk', name: '더스크', image: '더스크.webp',
+ difficulties: [
+ { key: 'normal', label: '노말', points: 20 },
+ { key: 'chaos', label: '카오스', points: 65 },
+ ],
+ },
+ {
+ key: 'darknell', name: '듄켈', image: '듄켈.webp',
+ difficulties: [
+ { key: 'normal', label: '노말', points: 25 },
+ { key: 'hard', label: '하드', points: 75 },
+ ],
+ },
+ {
+ key: 'jinhilla', name: '진 힐라', image: '진힐라.webp',
+ difficulties: [
+ { key: 'normal', label: '노말', points: 45 },
+ { key: 'hard', label: '하드', points: 90 },
+ ],
+ },
+]
+
+// 월간 보스
+export const MONTHLY_BOSSES = [
+ {
+ key: 'blackmage', name: '검은 마법사', image: '검은마법사.webp',
+ difficulties: [
+ { key: 'hard', label: '하드', points: 600 },
+ { key: 'extreme', label: '익스트림', points: 600 },
+ ],
+ },
+]
+
+export const BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/boss'
+export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
+
+// 파티 인원수로 점수 분배 (버림)
+export function calcPoints(basePoints, partySize) {
+ return Math.floor(basePoints / partySize)
+}
+
+// 목요일 기준 주차 계산 (KST)
+// 이번 주 목요일 자정 = 이번 주의 시작
+export function getThursdayOfWeek(date) {
+ const d = new Date(date)
+ const day = d.getDay() // 0=일, 4=목
+ // 직전 목요일 찾기 (오늘이 목요일이면 오늘)
+ const diff = (day - 4 + 7) % 7
+ d.setDate(d.getDate() - diff)
+ d.setHours(0, 0, 0, 0)
+ return d
+}
+
+export function formatDate(date) {
+ const d = new Date(date)
+ const y = d.getFullYear()
+ const m = String(d.getMonth() + 1).padStart(2, '0')
+ const day = String(d.getDate()).padStart(2, '0')
+ return `${y}-${m}-${day}`
+}
+
+export function addWeeks(date, weeks) {
+ const d = new Date(date)
+ d.setDate(d.getDate() + weeks * 7)
+ return d
+}