From f7b1c629f95ae1c85d8a4accd12a72f5fd911271 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 14 Apr 2026 00:22:46 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=B4=EB=B0=A9=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=A7=84=ED=96=89=EB=8F=84=20UI=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 제네시스 8챕터 세그먼트 바 + 보스 초상화 - 1차/2차 해방 구분, 예상 해방 날짜 표시 - 다크 테마 커스텀 DatePicker 컴포넌트 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/components/DatePicker.jsx | 237 ++++++++++++++++++ .../src/features/liberation/Liberation.jsx | 171 +++++++++++++ .../liberation/components/PointsInput.jsx | 45 ++++ .../liberation/components/ProgressBar.jsx | 98 ++++++++ .../liberation/components/QuestSelector.jsx | 70 ++++++ .../liberation/components/WeekCard.jsx | 134 ++++++++++ frontend/src/features/liberation/data.js | 120 +++++++++ 7 files changed, 875 insertions(+) create mode 100644 frontend/src/components/DatePicker.jsx create mode 100644 frontend/src/features/liberation/Liberation.jsx create mode 100644 frontend/src/features/liberation/components/PointsInput.jsx create mode 100644 frontend/src/features/liberation/components/ProgressBar.jsx create mode 100644 frontend/src/features/liberation/components/QuestSelector.jsx create mode 100644 frontend/src/features/liberation/components/WeekCard.jsx create mode 100644 frontend/src/features/liberation/data.js 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} +
+
+ {chapter.boss} +
+
+ ) + + return ( +
+ {/* 섹션 제목 */} +
퀘스트 진행 상황
+ + {/* 1차 / 2차 라벨 */} +
+
+ 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} + + {boss.name} + {earned > 0 && ( + +{earned} + )} +
+ {sel.enabled && ( +
+ updateBoss(boss.key, { party: v })} + options={PARTY_OPTIONS} + className="w-16" + /> +
+ )} +
+ ) + })} + + {/* 검은 마법사 (월 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} + + {boss.name} 월간 + {earned > 0 && ( + +{earned} + )} +
+ {sel.enabled && ( +
+ 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 +}