해방 계산기 진행도 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