해방 계산기 진행도 UI 초안 추가
- 제네시스 8챕터 세그먼트 바 + 보스 초상화 - 1차/2차 해방 구분, 예상 해방 날짜 표시 - 다크 테마 커스텀 DatePicker 컴포넌트 추가 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9dbc77ac14
commit
f7b1c629f9
7 changed files with 875 additions and 0 deletions
237
frontend/src/components/DatePicker.jsx
Normal file
237
frontend/src/components/DatePicker.jsx
Normal file
|
|
@ -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 (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${rotate}deg)` }} className={className}>
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 다크 테마 커스텀 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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setIsOpen(!isOpen))}
|
||||
className={`w-full h-12 rounded-lg border bg-gray-950 px-4 text-base flex items-center justify-between transition ${
|
||||
isOpen ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-gray-400">
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<AnimatePresence>
|
||||
{isOpen && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 left-0 rounded-xl border border-white/10 bg-gray-900 shadow-2xl p-5"
|
||||
style={{ width: 420 }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
||||
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
||||
className="p-1.5 rounded hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed text-gray-400"
|
||||
>
|
||||
<ChevronIcon dir="left" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-200 hover:text-emerald-300 transition"
|
||||
>
|
||||
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}년 ${month + 1}월`}
|
||||
<ChevronIcon dir={viewMode !== 'days' ? 'up' : 'down'} size={14} className="transition-transform" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? nextYearRange : nextMonth)}
|
||||
className="p-1.5 rounded hover:bg-white/5 text-gray-400"
|
||||
>
|
||||
<ChevronIcon dir="right" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' ? (
|
||||
<motion.div key="years" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">연도</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px', marginBottom: '12px' }}>
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectYear(y))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
year === y
|
||||
? 'bg-emerald-500 text-white'
|
||||
: currentYear === y
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">월</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px' }}>
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectMonth(i))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
month === i
|
||||
? 'bg-emerald-500 text-white'
|
||||
: (currentYear === year && currentMonth === i)
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div key="days" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: '6px', marginBottom: '8px' }}>
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400/80' : i === 6 ? 'text-sky-400/80' : 'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: '6px' }}>
|
||||
{days.map((day, i) => {
|
||||
const dw = i % 7
|
||||
const selected = isSelected(day)
|
||||
const today = isToday(day)
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={(e) => day && stop(e, () => selectDate(day))}
|
||||
style={{ aspectRatio: '1 / 1' }}
|
||||
className={`rounded-full text-base font-medium flex items-center justify-center transition-all
|
||||
${!day ? '' : 'hover:bg-white/5'}
|
||||
${selected ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}
|
||||
${today && !selected ? 'text-emerald-300 font-bold' : ''}
|
||||
${day && !selected && !today && dw === 0 ? 'text-red-400' : ''}
|
||||
${day && !selected && !today && dw === 6 ? 'text-sky-400' : ''}
|
||||
${day && !selected && !today && dw > 0 && dw < 6 ? 'text-gray-300' : ''}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
171
frontend/src/features/liberation/Liberation.jsx
Normal file
171
frontend/src/features/liberation/Liberation.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6">
|
||||
<ProgressBar
|
||||
totalAccumulated={totalCumulative}
|
||||
completionDate={isDone ? formatDate(completionDate) : null}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
frontend/src/features/liberation/components/PointsInput.jsx
Normal file
45
frontend/src/features/liberation/components/PointsInput.jsx
Normal file
|
|
@ -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 (
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={text}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
className={className}
|
||||
{...rest}
|
||||
/>
|
||||
)
|
||||
}
|
||||
98
frontend/src/features/liberation/components/ProgressBar.jsx
Normal file
98
frontend/src/features/liberation/components/ProgressBar.jsx
Normal file
|
|
@ -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 (
|
||||
<div key={`seg-${chapter.idx}`} className="flex-1 h-2 rounded bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all ${
|
||||
status === 'done' ? 'bg-emerald-500' :
|
||||
status === 'active' ? 'bg-amber-400' : 'bg-transparent'
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPortrait = ({ chapter, status }) => (
|
||||
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5">
|
||||
<div className={`w-14 h-14 rounded-lg overflow-hidden border transition ${
|
||||
status === 'done' ? 'border-emerald-500/40' :
|
||||
status === 'active' ? 'border-amber-400/60 shadow-lg shadow-amber-500/20' :
|
||||
'border-white/5 opacity-50'
|
||||
}`}>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
alt={chapter.boss}
|
||||
className={`w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
status === 'done' ? 'text-emerald-300' :
|
||||
status === 'active' ? 'text-amber-300' : 'text-gray-500'
|
||||
}`}>
|
||||
{chapter.boss}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-5">
|
||||
{/* 섹션 제목 */}
|
||||
<div className="text-lg font-semibold text-emerald-300">퀘스트 진행 상황</div>
|
||||
|
||||
{/* 1차 / 2차 라벨 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-base font-bold" style={{ color: '#5eead4' }}>1차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'rgba(94, 234, 212, 0.5)', borderRadius: 999 }} />
|
||||
</div>
|
||||
<div className="w-2" />
|
||||
<div className="flex-1 flex flex-col items-center gap-2">
|
||||
<span className="text-base font-bold" style={{ color: '#fda4af' }}>2차 해방</span>
|
||||
<div style={{ width: '100%', height: 3, background: 'rgba(253, 164, 175, 0.5)', borderRadius: 999 }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 세그먼트 바 (붙어있음) */}
|
||||
<div className="flex gap-1">
|
||||
{chapterStates.map(renderSegment)}
|
||||
</div>
|
||||
|
||||
{/* 초상화 (붙어있음) */}
|
||||
<div className="flex gap-1">
|
||||
{chapterStates.map(renderPortrait)}
|
||||
</div>
|
||||
|
||||
{/* 예상 해방 날짜 */}
|
||||
<div className="flex items-center justify-center gap-2 pt-4 border-t border-white/5 text-base">
|
||||
<span className="text-emerald-300/80">예상 해방 날짜</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="font-semibold tabular-nums text-amber-400">
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="text-gray-500 font-normal">미정</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div ref={ref} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`relative w-full h-12 flex items-center justify-center rounded-lg border bg-gray-950 px-3 transition ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||
alt={selected.boss}
|
||||
className="h-8 block"
|
||||
/>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
||||
className={`absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 z-50 flex flex-col items-center gap-1">
|
||||
{GENESIS_CHAPTERS.map((chapter) => {
|
||||
const isSelected = chapter.idx === value
|
||||
return (
|
||||
<button
|
||||
key={chapter.idx}
|
||||
type="button"
|
||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||
className={`relative transition ${
|
||||
isSelected ? 'scale-105' : 'opacity-60 hover:opacity-100'
|
||||
}`}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BTBOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
alt={chapter.boss}
|
||||
className="h-10 block drop-shadow-lg"
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
frontend/src/features/liberation/components/WeekCard.jsx
Normal file
134
frontend/src/features/liberation/components/WeekCard.jsx
Normal file
|
|
@ -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 (
|
||||
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden">
|
||||
{/* 헤더: 주차 번호 + 날짜 + 이번 주 획득 + 누적 */}
|
||||
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-950/60 border-b border-white/5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-semibold">{weekNumber}주차</div>
|
||||
<div className="text-xs text-gray-500">{formatDate(weekData.startDate)}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<div className="text-gray-400">
|
||||
획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalThisWeek}</span>
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
누적 <span className="text-white font-semibold tabular-nums">{cumulativePoints}</span>
|
||||
</div>
|
||||
{chapterInfo && (
|
||||
<div className="text-xs text-gray-500">
|
||||
{chapterInfo.name} {chapterInfo.current}/{chapterInfo.required}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 보스 그리드 */}
|
||||
<div className="p-3 grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{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 (
|
||||
<div key={boss.key} className={`rounded-lg border p-2 transition ${sel.enabled ? 'border-white/10 bg-gray-950/40' : 'border-white/5 bg-transparent opacity-60'}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
checked={sel.enabled}
|
||||
onChange={(v) => updateBoss(boss.key, { enabled: v })}
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip text={boss.name}>
|
||||
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
|
||||
</Tooltip>
|
||||
<span className="text-xs font-medium truncate flex-1">{boss.name}</span>
|
||||
{earned > 0 && (
|
||||
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
|
||||
)}
|
||||
</div>
|
||||
{sel.enabled && (
|
||||
<div className="flex gap-1.5">
|
||||
<Select
|
||||
value={sel.difficulty}
|
||||
onChange={(v) => updateBoss(boss.key, { difficulty: v })}
|
||||
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={sel.party}
|
||||
onChange={(v) => updateBoss(boss.key, { party: v })}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* 검은 마법사 (월 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 (
|
||||
<div key={boss.key} className={`rounded-lg border p-2 transition col-span-2 sm:col-span-2 ${
|
||||
sel.enabled ? 'border-amber-500/40 bg-amber-500/[0.05]' : 'border-white/5 bg-transparent opacity-60'
|
||||
}`}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Checkbox
|
||||
checked={sel.enabled}
|
||||
onChange={(v) => updateBlackMage({ enabled: v })}
|
||||
size="sm"
|
||||
/>
|
||||
<Tooltip text={`${boss.name} (월 1회)`}>
|
||||
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
|
||||
</Tooltip>
|
||||
<span className="text-xs font-medium flex-1">{boss.name} <span className="text-[10px] text-amber-400">월간</span></span>
|
||||
{earned > 0 && (
|
||||
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
|
||||
)}
|
||||
</div>
|
||||
{sel.enabled && (
|
||||
<div className="flex gap-1.5">
|
||||
<Select
|
||||
value={sel.difficulty}
|
||||
onChange={(v) => updateBlackMage({ difficulty: v })}
|
||||
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select
|
||||
value={sel.party}
|
||||
onChange={(v) => updateBlackMage({ party: v })}
|
||||
options={PARTY_OPTIONS}
|
||||
className="w-16"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
120
frontend/src/features/liberation/data.js
Normal file
120
frontend/src/features/liberation/data.js
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue