해방 계산기 진행도 UI 초안 추가

- 제네시스 8챕터 세그먼트 바 + 보스 초상화
- 1차/2차 해방 구분, 예상 해방 날짜 표시
- 다크 테마 커스텀 DatePicker 컴포넌트 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 00:22:46 +09:00
parent 9dbc77ac14
commit f7b1c629f9
7 changed files with 875 additions and 0 deletions

View 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>
)
}

View 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>
)
}

View 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}
/>
)
}

View 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>
)
}

View file

@ -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>
)
}

View 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>
)
}

View 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
}