Compare commits
9 commits
9dbc77ac14
...
e418e651b8
| Author | SHA1 | Date | |
|---|---|---|---|
| e418e651b8 | |||
| 61822345bf | |||
| 85f2d9c482 | |||
| 1163f77266 | |||
| 8eaf27d143 | |||
| f0c0ea3c1c | |||
| f27c46f68d | |||
| 0c6ccecc90 | |||
| f7b1c629f9 |
19 changed files with 1464 additions and 76 deletions
19
backend/routes/images.js
Normal file
19
backend/routes/images.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Router } from 'express';
|
||||
import { Image } from '../models/index.js';
|
||||
import { getPublicUrl } from '../lib/s3.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 이름으로 이미지 URL 조회 (공개)
|
||||
router.get('/:name', async (req, res) => {
|
||||
try {
|
||||
const image = await Image.findOne({ where: { name: req.params.name } });
|
||||
if (!image) return res.status(404).json({ error: '이미지 없음' });
|
||||
res.json({ name: image.name, url: getPublicUrl(image.path) });
|
||||
} catch (err) {
|
||||
console.error('이미지 조회 오류:', err.message);
|
||||
res.status(500).json({ error: '이미지 조회 실패' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
@ -5,6 +5,7 @@ import menuRoutes from './routes/menus.js';
|
|||
import noticeRoutes from './routes/notices.js';
|
||||
import bossCrystalRoutes from './routes/boss-crystal.js';
|
||||
import characterRoutes from './routes/character.js';
|
||||
import imageRoutes from './routes/images.js';
|
||||
import { sequelize } from './lib/db.js';
|
||||
import './models/index.js';
|
||||
|
||||
|
|
@ -23,6 +24,7 @@ app.use('/api/menus', menuRoutes);
|
|||
app.use('/api/notices', noticeRoutes);
|
||||
app.use('/api/boss-crystal', bossCrystalRoutes);
|
||||
app.use('/api/character', characterRoutes);
|
||||
app.use('/api/images', imageRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
|
|
|
|||
25
frontend/package-lock.json
generated
25
frontend/package-lock.json
generated
|
|
@ -12,7 +12,10 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.91.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "^12.23.22",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0"
|
||||
|
|
@ -1522,6 +1525,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.20",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz",
|
||||
"integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
|
|
@ -2545,6 +2554,22 @@
|
|||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/overlayscrollbars": {
|
||||
"version": "2.15.1",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz",
|
||||
"integrity": "sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/overlayscrollbars-react": {
|
||||
"version": "0.5.6",
|
||||
"resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz",
|
||||
"integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"overlayscrollbars": "^2.0.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@
|
|||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@tanstack/react-query": "^5.91.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"framer-motion": "^12.23.22",
|
||||
"overlayscrollbars": "^2.15.1",
|
||||
"overlayscrollbars-react": "^0.5.6",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.14.0"
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
export default function ConfirmDialog({
|
||||
open,
|
||||
onClose,
|
||||
|
|
@ -9,38 +11,77 @@ export default function ConfirmDialog({
|
|||
destructive = false,
|
||||
loading = false,
|
||||
}) {
|
||||
if (!open) return null
|
||||
const accent = destructive
|
||||
? { ring: 'ring-red-500/20', icon: 'text-red-300', iconBg: 'bg-red-500/10 border-red-500/30' }
|
||||
: { ring: 'ring-emerald-500/20', icon: 'text-emerald-300', iconBg: 'bg-emerald-500/10 border-emerald-500/30' }
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
|
||||
<div className="w-full max-w-md rounded-2xl bg-gray-900 border border-white/10 shadow-2xl" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="font-semibold">{title}</h3>
|
||||
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<p className="text-sm text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 px-6 py-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-white/10 px-4 py-2 text-sm hover:bg-white/5 transition"
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
key="backdrop"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.18 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-md"
|
||||
onClick={onClose}
|
||||
>
|
||||
<motion.div
|
||||
key="dialog"
|
||||
initial={{ opacity: 0, scale: 0.94, y: 8 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.96, y: 4 }}
|
||||
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
|
||||
className={`w-full max-w-md rounded-2xl bg-gradient-to-b from-gray-900 to-gray-950 border border-white/10 shadow-2xl ring-1 ${accent.ring}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition disabled:opacity-50 ${
|
||||
destructive
|
||||
? 'bg-red-600 hover:bg-red-500 shadow-lg shadow-red-500/20'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500'
|
||||
}`}
|
||||
>
|
||||
{loading ? '처리 중...' : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-7 pt-7 pb-3 flex items-start gap-4">
|
||||
<div className={`shrink-0 w-11 h-11 rounded-xl border flex items-center justify-center ${accent.iconBg}`}>
|
||||
{destructive ? (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
|
||||
<path d="M12 9V13M12 17H12.01M10.29 3.86L1.82 18C1.64 18.31 1.55 18.67 1.55 19.03C1.55 19.4 1.65 19.76 1.83 20.07C2 20.39 2.26 20.65 2.57 20.83C2.88 21.01 3.24 21.1 3.6 21.1H20.47C20.83 21.1 21.19 21.01 21.5 20.83C21.81 20.65 22.07 20.39 22.24 20.07C22.42 19.76 22.52 19.4 22.52 19.03C22.52 18.67 22.43 18.31 22.25 18L13.78 3.86C13.6 3.56 13.35 3.31 13.04 3.14C12.74 2.96 12.4 2.87 12.06 2.87C11.72 2.87 11.38 2.96 11.08 3.14C10.77 3.31 10.52 3.56 10.34 3.86H10.29Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" className={accent.icon}>
|
||||
<path d="M12 8V12M12 16H12.01M22 12C22 17.52 17.52 22 12 22C6.48 22 2 17.52 2 12C2 6.48 6.48 2 12 2C17.52 2 22 6.48 22 12Z" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)}
|
||||
</div>
|
||||
<h3 className="flex-1 text-xl font-bold text-white pt-1.5">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="shrink-0 w-8 h-8 -mt-1 -mr-1 rounded-lg text-gray-500 hover:text-white hover:bg-white/5 transition flex items-center justify-center text-xl leading-none"
|
||||
aria-label="닫기"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-7 pt-4 pb-7">
|
||||
<p className="text-lg text-gray-300 leading-relaxed whitespace-pre-line">{description}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 px-7 py-4 border-t border-white/5">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 rounded-lg border border-white/10 bg-white/[0.02] hover:bg-white/[0.06] text-gray-200 px-4 h-11 text-sm font-medium transition"
|
||||
>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
disabled={loading}
|
||||
className={`flex-1 rounded-lg px-4 h-11 text-sm font-semibold transition disabled:opacity-50 ${
|
||||
destructive
|
||||
? 'bg-red-600 hover:bg-red-500 text-white shadow-lg shadow-red-500/20'
|
||||
: 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-lg shadow-emerald-500/20'
|
||||
}`}
|
||||
>
|
||||
{loading ? '처리 중...' : confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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>
|
||||
)
|
||||
}
|
||||
|
|
@ -57,7 +57,7 @@ export default function Layout() {
|
|||
|
||||
return (
|
||||
<LayoutContext.Provider value={{ fullscreen, setFullscreen }}>
|
||||
<div className={`min-w-[1280px] bg-gradient-to-br from-gray-950 via-gray-950 to-slate-900 text-white flex flex-col ${
|
||||
<div className={`min-w-[1280px] text-white flex flex-col ${
|
||||
fullscreen ? 'h-dvh' : 'min-h-screen'
|
||||
}`}>
|
||||
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
|
||||
/**
|
||||
* 커스텀 드롭다운 셀렉트
|
||||
|
|
@ -37,33 +38,41 @@ export default function Select({ value, onChange, options, disabled, className =
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className={`absolute top-full mt-1 z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden ${
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
}`}>
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(opt.value); setOpen(false) }}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm transition flex items-center gap-2 ${
|
||||
opt.value === value
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{opt.value === value && (
|
||||
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className={`absolute top-full mt-1 z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden origin-top ${
|
||||
align === 'right' ? 'right-0' : 'left-0'
|
||||
}`}
|
||||
>
|
||||
<div className="max-h-60 overflow-y-auto py-1">
|
||||
{options.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => { onChange(opt.value); setOpen(false) }}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm transition flex items-center gap-2 ${
|
||||
opt.value === value
|
||||
? 'bg-emerald-500/10 text-emerald-300'
|
||||
: 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{opt.value === value && (
|
||||
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||
</svg>
|
||||
)}
|
||||
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -185,7 +185,12 @@ function ImageCard({ image, selected, selectMode, onToggle, onCopyUrl, copied })
|
|||
)}
|
||||
|
||||
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-4 relative">
|
||||
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
|
||||
<img
|
||||
src={image.url}
|
||||
alt={image.name}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ imageRendering: 'pixelated' }}
|
||||
/>
|
||||
|
||||
{!selectMode && (
|
||||
<div className="absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition">
|
||||
|
|
@ -464,7 +469,7 @@ export default function AdminImages() {
|
|||
|
||||
{/* 이미지 그리드 */}
|
||||
{isLoading ? (
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="aspect-square rounded-xl bg-white/[0.02] animate-pulse" />
|
||||
))}
|
||||
|
|
@ -486,7 +491,7 @@ export default function AdminImages() {
|
|||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4">
|
||||
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
|
||||
{images.map((image) => (
|
||||
<ImageCard
|
||||
key={image.id}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Reorder } from 'framer-motion'
|
||||
import { Reorder, useDragControls } from 'framer-motion'
|
||||
import { api } from '../../../api/client'
|
||||
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
|
|
@ -101,10 +101,13 @@ function CharacterContent({ char, selections, bosses }) {
|
|||
|
||||
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
|
||||
const [dragged, setDragged] = useState(false)
|
||||
const dragControls = useDragControls()
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
value={char}
|
||||
dragListener={false}
|
||||
dragControls={dragControls}
|
||||
onDragStart={() => setDragged(true)}
|
||||
onDragEnd={() => {
|
||||
// 다음 click 이벤트 후에 reset
|
||||
|
|
@ -115,14 +118,18 @@ function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemov
|
|||
if (e.target.closest('button')) return
|
||||
onSelect(char.character_name)
|
||||
}}
|
||||
className={`group relative rounded-xl border cursor-grab active:cursor-grabbing select-none ${
|
||||
className={`group relative rounded-xl border cursor-pointer select-none ${
|
||||
isSelected
|
||||
? 'border-emerald-500/40 bg-emerald-500/[0.08]'
|
||||
: 'border-white/5 hover:border-white/15 bg-gray-950/40 hover:bg-gray-950/60'
|
||||
}`}
|
||||
>
|
||||
{/* 드래그 핸들 아이콘 (시각적 표시용) */}
|
||||
<div className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-600 pointer-events-none">
|
||||
{/* 드래그 핸들 */}
|
||||
<div
|
||||
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
|
||||
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center text-gray-600 hover:text-gray-400 cursor-grab active:cursor-grabbing"
|
||||
style={{ touchAction: 'none' }}
|
||||
>
|
||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
|
||||
<circle cx="3" cy="3" r="1.2" />
|
||||
<circle cx="9" cy="3" r="1.2" />
|
||||
|
|
|
|||
389
frontend/src/features/liberation/Liberation.jsx
Normal file
389
frontend/src/features/liberation/Liberation.jsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import dayjs from 'dayjs'
|
||||
import { api } from '../../api/client'
|
||||
import {
|
||||
GENESIS_CHAPTERS,
|
||||
GENESIS_TOTAL,
|
||||
WEEKLY_BOSSES,
|
||||
MONTHLY_BOSSES,
|
||||
calcPoints,
|
||||
addWeeks,
|
||||
formatDate,
|
||||
todayKST,
|
||||
} from './data'
|
||||
import WeekCard from './components/WeekCard'
|
||||
import QuestSelector from './components/QuestSelector'
|
||||
import PointsInput from './components/PointsInput'
|
||||
import ProgressBar from './components/ProgressBar'
|
||||
import WeeklyDefault from './components/WeeklyDefault'
|
||||
import DatePicker from '../../components/DatePicker'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useLayout } from '../../components/Layout'
|
||||
|
||||
const STORAGE_KEY = 'maple-liberation'
|
||||
|
||||
function makeEmptyWeek(startDate) {
|
||||
return {
|
||||
startDate: dayjs(startDate).toISOString(),
|
||||
...makeEmptyWeekly(),
|
||||
}
|
||||
}
|
||||
|
||||
function makeEmptyWeekly() {
|
||||
const bosses = {}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
bosses[b.key] = { difficulty: 'none', party: 1, done: false }
|
||||
})
|
||||
return {
|
||||
bosses,
|
||||
blackMage: { difficulty: 'none', party: 1, done: false },
|
||||
}
|
||||
}
|
||||
|
||||
function bossEarn(boss, sel) {
|
||||
if (!sel) return 0
|
||||
const d = boss.difficulties.find((x) => x.key === sel.difficulty)
|
||||
if (!d) return 0
|
||||
return calcPoints(d.points, sel.party)
|
||||
}
|
||||
|
||||
function calcWeekPoints(weekData) {
|
||||
let points = 0
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
points += bossEarn(b, weekData.bosses[b.key])
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
||||
function calcDoneEarn(weekData) {
|
||||
let points = 0
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
const sel = weekData.bosses[b.key]
|
||||
if (sel?.done) points += bossEarn(b, sel)
|
||||
})
|
||||
return points
|
||||
}
|
||||
|
||||
function calcMonthlyEarn(weekData) {
|
||||
return bossEarn(MONTHLY_BOSSES[0], weekData.blackMage)
|
||||
}
|
||||
|
||||
function calcMonthlyDoneEarn(weekData) {
|
||||
return weekData.blackMage?.done ? bossEarn(MONTHLY_BOSSES[0], weekData.blackMage) : 0
|
||||
}
|
||||
|
||||
export default function Liberation() {
|
||||
const { setFullscreen } = useLayout()
|
||||
useEffect(() => {
|
||||
setFullscreen(true)
|
||||
return () => setFullscreen(false)
|
||||
}, [setFullscreen])
|
||||
|
||||
const [liberationType, setLiberationType] = useState('genesis') // 'genesis' | 'destiny'
|
||||
|
||||
const genesisImg = useQuery({
|
||||
queryKey: ['image', '제네시스 스태프'],
|
||||
queryFn: () => api('/api/images/' + encodeURIComponent('제네시스 스태프')).catch(() => null),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
const destinyImg = useQuery({
|
||||
queryKey: ['image', '데스티니 스태프'],
|
||||
queryFn: () => api('/api/images/' + encodeURIComponent('데스티니 스태프')).catch(() => null),
|
||||
staleTime: Infinity,
|
||||
})
|
||||
|
||||
const [state, setState] = useState(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved) {
|
||||
try {
|
||||
const parsed = JSON.parse(saved)
|
||||
if (!parsed.weekly) parsed.weekly = makeEmptyWeekly()
|
||||
if (!parsed.startDate) parsed.startDate = dayjs(todayKST()).toISOString()
|
||||
if (!parsed.weekOverrides) parsed.weekOverrides = {}
|
||||
// enabled/'none' 필드 제거 마이그레이션
|
||||
const migrate = (sel, defaultDiff) => {
|
||||
if (!sel) return sel
|
||||
if (!sel.difficulty || sel.difficulty === 'none') sel.difficulty = defaultDiff
|
||||
delete sel.enabled
|
||||
return sel
|
||||
}
|
||||
WEEKLY_BOSSES.forEach((b) => {
|
||||
if (parsed.weekly.bosses?.[b.key]) {
|
||||
parsed.weekly.bosses[b.key] = migrate(parsed.weekly.bosses[b.key], b.difficulties[0].key)
|
||||
}
|
||||
})
|
||||
parsed.weekly.blackMage = migrate(parsed.weekly.blackMage, MONTHLY_BOSSES[0].difficulties[0].key)
|
||||
return parsed
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return {
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
weekOverrides: {},
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
const currentChapterCap = GENESIS_CHAPTERS[state.startChapter]?.required ?? 0
|
||||
const clampedCurrent = Math.min(state.currentPoints, currentChapterCap)
|
||||
let totalAccumulated = startConsumedBefore + clampedCurrent
|
||||
|
||||
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])
|
||||
|
||||
// 포인트 이월 계산: 현재 퀘스트의 required를 초과하면 자동으로 다음 퀘스트로 넘어감
|
||||
const priorConsumed = GENESIS_CHAPTERS
|
||||
.slice(0, state.startChapter)
|
||||
.reduce((s, c) => s + c.required, 0)
|
||||
let cascadeIdx = state.startChapter
|
||||
let cascadeRemain = state.currentPoints
|
||||
let cascadeConsumed = 0
|
||||
while (cascadeIdx < GENESIS_CHAPTERS.length && cascadeRemain >= GENESIS_CHAPTERS[cascadeIdx].required) {
|
||||
cascadeConsumed += GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeRemain -= GENESIS_CHAPTERS[cascadeIdx].required
|
||||
cascadeIdx++
|
||||
}
|
||||
const initialAccumulated = priorConsumed + cascadeConsumed + cascadeRemain
|
||||
const alreadyDone = initialAccumulated >= GENESIS_TOTAL
|
||||
const weeklyEarn = calcWeekPoints(state.weekly)
|
||||
const remaining = Math.max(GENESIS_TOTAL - initialAccumulated, 0)
|
||||
// 주간/월간 분리
|
||||
const doneEarn = calcDoneEarn(state.weekly)
|
||||
const monthlyEarn = calcMonthlyEarn(state.weekly)
|
||||
const monthlyDoneThisMonth = !!state.weekly.blackMage?.done
|
||||
|
||||
// 날짜 이벤트 시뮬레이션으로 해방일 계산
|
||||
function computeCompletionDate() {
|
||||
if (alreadyDone) return todayKST()
|
||||
if (weeklyEarn === 0 && monthlyEarn === 0) return null
|
||||
if (remaining <= 0) return dayjs(state.startDate).tz('Asia/Seoul').startOf('day').toDate()
|
||||
|
||||
const startKST = dayjs(state.startDate).tz('Asia/Seoul').startOf('day')
|
||||
const events = []
|
||||
|
||||
// 시작일 당일: (주간 - 완료된 주간) + (이번 달 월간, 아직 안 잡았을 때)
|
||||
const day0Weekly = Math.max(weeklyEarn - doneEarn, 0)
|
||||
const day0Monthly = monthlyEarn > 0 && !monthlyDoneThisMonth ? monthlyEarn : 0
|
||||
events.push({ date: startKST, amount: day0Weekly + day0Monthly })
|
||||
|
||||
// 다음 목요일부터 매주 주간 적립
|
||||
const dow = startKST.day()
|
||||
const daysToNextThu = dow < 4 ? 4 - dow : 11 - dow
|
||||
let nextThu = startKST.add(daysToNextThu, 'day')
|
||||
for (let i = 0; i < 520; i++) {
|
||||
events.push({ date: nextThu, amount: weeklyEarn })
|
||||
nextThu = nextThu.add(1, 'week')
|
||||
}
|
||||
|
||||
// 다음 달 1일부터 매월 월간 적립
|
||||
if (monthlyEarn > 0) {
|
||||
let nextMonth = startKST.add(1, 'month').startOf('month')
|
||||
for (let i = 0; i < 120; i++) {
|
||||
events.push({ date: nextMonth, amount: monthlyEarn })
|
||||
nextMonth = nextMonth.add(1, 'month')
|
||||
}
|
||||
}
|
||||
|
||||
events.sort((a, b) => a.date.diff(b.date))
|
||||
let cumulative = 0
|
||||
for (const e of events) {
|
||||
cumulative += e.amount
|
||||
if (cumulative >= remaining) return e.date.toDate()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const completionDate = computeCompletionDate()
|
||||
const isDone = completionDate !== 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(lastWeek.startDate, 1)
|
||||
return {
|
||||
...prev,
|
||||
weeks: [...prev.weeks, { ...lastWeek, startDate: dayjs(nextStart).toISOString() }],
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const removeWeek = (idx) => {
|
||||
setState((prev) => ({ ...prev, weeks: prev.weeks.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
|
||||
const [resetOpen, setResetOpen] = useState(false)
|
||||
const doReset = () => {
|
||||
setState({
|
||||
startChapter: 0,
|
||||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
weekOverrides: {},
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
})
|
||||
setResetOpen(false)
|
||||
}
|
||||
|
||||
const setFirstWeekDate = (dateStr) => {
|
||||
setState((prev) => {
|
||||
const weeks = prev.weeks.map((w, i) => ({
|
||||
...w,
|
||||
startDate: dayjs(addWeeks(dateStr, 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 pb-10">
|
||||
{/* 해방 종류 탭 */}
|
||||
<div className="max-w-2xl mx-auto flex gap-2">
|
||||
{[
|
||||
{ key: 'genesis', label: '제네시스 해방', img: genesisImg.data?.url },
|
||||
{ key: 'destiny', label: '데스티니 해방', img: destinyImg.data?.url },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setLiberationType(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3 transition ${
|
||||
liberationType === tab.key
|
||||
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-lg shadow-emerald-500/10'
|
||||
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
|
||||
<span className="text-base font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{liberationType === 'destiny' ? (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-16 text-center space-y-3 flex flex-col items-center justify-center" style={{ minHeight: 'calc(100vh - 220px)' }}>
|
||||
<div className="text-2xl font-bold text-gray-300">구현 예정</div>
|
||||
<div className="text-sm text-gray-500">데스티니 해방 계산기는 준비 중입니다.</div>
|
||||
</div>
|
||||
) : (<>
|
||||
<ProgressBar
|
||||
startChapter={state.startChapter}
|
||||
currentPoints={state.currentPoints}
|
||||
completionDate={isDone ? formatDate(completionDate) : null}
|
||||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div className="text-lg font-semibold text-emerald-300">현재 진행 상태</div>
|
||||
|
||||
<div className="grid gap-3" style={{ gridTemplateColumns: '1.2fr 1.2fr 0.7fr' }}>
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">시작 날짜</label>
|
||||
<DatePicker
|
||||
value={formatDate(state.startDate)}
|
||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">현재 흔적</label>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="w-full h-12 rounded-lg border border-white/10 bg-gray-950 px-3 text-base text-right tabular-nums outline-none focus:border-emerald-500/50 hover:border-white/20 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WeeklyDefault
|
||||
weekly={state.weekly}
|
||||
onChange={(w) => setState((prev) => ({ ...prev, weekly: w }))}
|
||||
totalWeekly={weeklyEarn}
|
||||
totalMonthly={monthlyEarn}
|
||||
/>
|
||||
|
||||
<div className="max-w-2xl mx-auto flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setResetOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-300 hover:text-red-200 px-5 py-2.5 text-sm font-semibold transition shadow-lg shadow-red-500/10"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
전체 초기화
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={doReset}
|
||||
title="전체 초기화"
|
||||
description={'입력한 내용을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.'}
|
||||
confirmText="초기화"
|
||||
destructive
|
||||
/>
|
||||
</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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
90
frontend/src/features/liberation/components/ProgressBar.jsx
Normal file
90
frontend/src/features/liberation/components/ProgressBar.jsx
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
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({ startChapter, currentPoints, completionDate }) {
|
||||
const chapterStates = GENESIS_CHAPTERS.map((c) => {
|
||||
if (c.idx < startChapter) return { chapter: c, status: 'done', current: c.required }
|
||||
if (c.idx === startChapter) {
|
||||
const filled = Math.min(currentPoints, c.required)
|
||||
return { chapter: c, status: filled > 0 ? 'active' : 'active', current: filled }
|
||||
}
|
||||
return { chapter: c, status: 'pending', current: 0 }
|
||||
})
|
||||
|
||||
const renderSegment = ({ chapter, status, current }) => {
|
||||
const pct = (current / chapter.required) * 100
|
||||
const bg = status === 'done' ? '#10b981' : status === 'active' ? '#fbbf24' : 'transparent'
|
||||
return (
|
||||
<div key={`seg-${chapter.idx}`} className="flex-1 h-2 rounded bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: bg }}
|
||||
/>
|
||||
</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="space-y-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<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="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>
|
||||
|
||||
{/* 초상화 (붙어있음) */}
|
||||
<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,91 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data'
|
||||
|
||||
/**
|
||||
* 진행 중인 퀘스트 드롭다운
|
||||
* - 보스 초상화 + 이름 텍스트
|
||||
*/
|
||||
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={`w-full h-12 flex items-center gap-3 rounded-lg border bg-gray-950 pl-2 pr-3 transition ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.png`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-100">
|
||||
{selected.boss}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
||||
className={`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>
|
||||
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-white/10 bg-gray-900 shadow-2xl py-1 max-h-72 overflow-y-auto origin-top"
|
||||
>
|
||||
{GENESIS_CHAPTERS.map((chapter) => {
|
||||
const isSelected = chapter.idx === value
|
||||
return (
|
||||
<button
|
||||
key={chapter.idx}
|
||||
type="button"
|
||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.png`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className={`flex-1 text-left text-sm font-medium ${
|
||||
isSelected ? 'text-emerald-300' : 'text-gray-200'
|
||||
}`}>
|
||||
{chapter.boss}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
143
frontend/src/features/liberation/components/WeeklyDefault.jsx
Normal file
143
frontend/src/features/liberation/components/WeeklyDefault.jsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import { useState } from 'react'
|
||||
import Select from '../../../components/Select'
|
||||
import Tooltip from '../../../components/Tooltip'
|
||||
import { WEEKLY_BOSSES, MONTHLY_BOSSES, LIBERATION_BOSS_IMAGE_BASE, calcPoints } from '../data'
|
||||
|
||||
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
||||
|
||||
function diffLabel(d, party) {
|
||||
if (d.key === 'none') return <span className="text-gray-500">격파 불가</span>
|
||||
const earned = calcPoints(d.points, party)
|
||||
return (
|
||||
<span>
|
||||
{d.label} <span className="text-emerald-400">+{earned}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function BossRow({ boss, sel, onChange, monthly = false }) {
|
||||
const disabled = sel.difficulty === 'none'
|
||||
const difficultyOptions = [NONE_DIFFICULTY, ...boss.difficulties]
|
||||
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 h-14 transition">
|
||||
<Tooltip text={boss.name}>
|
||||
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-8 h-8 rounded object-cover shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="text-sm font-medium flex-1 truncate">
|
||||
{boss.name}
|
||||
{monthly && <span className="ml-1.5 text-[10px] text-amber-400/80">월간</span>}
|
||||
</span>
|
||||
|
||||
<div className="w-36">
|
||||
<Select
|
||||
value={sel.difficulty}
|
||||
onChange={(v) => {
|
||||
if (v === 'none') onChange({ difficulty: 'none', done: false })
|
||||
else onChange({ difficulty: v })
|
||||
}}
|
||||
options={difficultyOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20">
|
||||
<Select
|
||||
value={sel.party}
|
||||
onChange={(v) => onChange({ party: v })}
|
||||
options={PARTY_OPTIONS}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ done: !sel.done })}
|
||||
className={`shrink-0 w-20 rounded-md h-8 text-xs font-semibold transition border ${
|
||||
disabled
|
||||
? 'border-white/5 text-gray-700 cursor-not-allowed'
|
||||
: sel.done
|
||||
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
|
||||
: 'border-white/10 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{sel.done ? '완료' : '미완료'}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMonthly }) {
|
||||
const [mode, setMode] = useState('simple') // 'simple' | 'weekly'
|
||||
|
||||
const updateBoss = (key, patch) => {
|
||||
onChange({ ...weekly, bosses: { ...weekly.bosses, [key]: { ...weekly.bosses[key], ...patch } } })
|
||||
}
|
||||
const updateBlackMage = (patch) => {
|
||||
onChange({ ...weekly, blackMage: { ...weekly.blackMage, ...patch } })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
||||
<div className="inline-flex rounded-lg border border-white/10 bg-gray-950 p-0.5">
|
||||
<TabButton active={mode === 'simple'} onClick={() => setMode('simple')}>단순 계산</TabButton>
|
||||
<TabButton active={mode === 'weekly'} onClick={() => setMode('weekly')}>주차별 계산</TabButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
<>
|
||||
<div className="flex items-baseline justify-end text-sm text-gray-400 gap-3">
|
||||
<span>
|
||||
주간 획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalWeekly}</span>
|
||||
</span>
|
||||
<span>
|
||||
월간 획득 <span className="text-amber-300 font-semibold tabular-nums">+{totalMonthly}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="divide-y divide-white/5">
|
||||
{WEEKLY_BOSSES.map((boss) => (
|
||||
<BossRow
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
/>
|
||||
))}
|
||||
{MONTHLY_BOSSES.map((boss) => (
|
||||
<BossRow
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.blackMage}
|
||||
onChange={updateBlackMage}
|
||||
monthly
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="py-12 text-center text-sm text-gray-500">
|
||||
주차별 계산 UI 준비 중
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TabButton({ active, onClick, children }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`px-3 h-8 rounded-md text-sm font-medium transition ${
|
||||
active
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'text-gray-400 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
126
frontend/src/features/liberation/data.js
Normal file
126
frontend/src/features/liberation/data.js
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// 제네시스 해방 챕터 (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 LIBERATION_BOSS_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/liberation/genesis/boss'
|
||||
|
||||
export const GENESIS_TOTAL = GENESIS_CHAPTERS.reduce((s, c) => s + c.required, 0) // 6500
|
||||
|
||||
// 주간 보스 (주 1회)
|
||||
export const WEEKLY_BOSSES = [
|
||||
{
|
||||
key: 'lotus', name: '스우', image: '스우.png',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 10 },
|
||||
{ key: 'hard', label: '하드', points: 50 },
|
||||
{ key: 'extreme', label: '익스트림', points: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'damien', name: '데미안', image: '데미안.png',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 10 },
|
||||
{ key: 'hard', label: '하드', points: 50 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'lucid', name: '루시드', image: '루시드.png',
|
||||
difficulties: [
|
||||
{ key: 'easy', label: '이지', points: 15 },
|
||||
{ key: 'normal', label: '노말', points: 20 },
|
||||
{ key: 'hard', label: '하드', points: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'will', name: '윌', image: '윌.png',
|
||||
difficulties: [
|
||||
{ key: 'easy', label: '이지', points: 15 },
|
||||
{ key: 'normal', label: '노말', points: 25 },
|
||||
{ key: 'hard', label: '하드', points: 75 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'dusk', name: '더스크', image: '더스크.png',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 20 },
|
||||
{ key: 'chaos', label: '카오스', points: 65 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'jinhilla', name: '진 힐라', image: '진 힐라.png',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 45 },
|
||||
{ key: 'hard', label: '하드', points: 90 },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'darknell', name: '듄켈', image: '듄켈.png',
|
||||
difficulties: [
|
||||
{ key: 'normal', label: '노말', points: 25 },
|
||||
{ key: 'hard', label: '하드', points: 75 },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
// 월간 보스
|
||||
export const MONTHLY_BOSSES = [
|
||||
{
|
||||
key: 'blackmage', name: '검은 마법사', image: '검은 마법사.png',
|
||||
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 = dayjs(date).tz(KST)
|
||||
const day = d.day() // 0=일, 4=목
|
||||
const diff = (day - 4 + 7) % 7
|
||||
return d.subtract(diff, 'day').startOf('day').toDate()
|
||||
}
|
||||
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
const KST = 'Asia/Seoul'
|
||||
|
||||
export function formatDate(date) {
|
||||
return dayjs(date).tz(KST).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
export function addWeeks(date, weeks) {
|
||||
return dayjs(date).tz(KST).add(weeks, 'week').toDate()
|
||||
}
|
||||
|
||||
export function todayKST() {
|
||||
return dayjs().tz(KST).startOf('day').toDate()
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
@import "tailwindcss";
|
||||
@import "overlayscrollbars/overlayscrollbars.css";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Maplestory", "Noto Sans KR", system-ui, -apple-system, sans-serif;
|
||||
|
|
@ -6,8 +7,19 @@
|
|||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
background: #030712;
|
||||
min-height: 100%;
|
||||
background: linear-gradient(to bottom right, #030712, #030712, #0f172a);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* OverlayScrollbars body 오버레이 테마 */
|
||||
.os-theme-maple.os-theme-dark {
|
||||
--os-handle-bg: rgba(255, 255, 255, 0.25);
|
||||
--os-handle-bg-hover: rgba(255, 255, 255, 0.4);
|
||||
--os-handle-bg-active: rgba(255, 255, 255, 0.5);
|
||||
--os-size: 12px;
|
||||
--os-padding-perpendicular: 2px;
|
||||
--os-padding-axis: 2px;
|
||||
}
|
||||
|
||||
html {
|
||||
|
|
@ -46,26 +58,27 @@ input[type="number"] {
|
|||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
/* 커스텀 스크롤바 */
|
||||
* {
|
||||
|
||||
/* 내부 스크롤 영역만 얇은 커스텀 스크롤바 (메인 페이지 스크롤은 기본) */
|
||||
*:not(html):not(body) {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
*:not(html):not(body)::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
*::-webkit-scrollbar-track {
|
||||
*:not(html):not(body)::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
*:not(html):not(body)::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-radius: 4px;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
*:not(html):not(body)::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
}
|
||||
*::-webkit-scrollbar-corner {
|
||||
*:not(html):not(body)::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,9 +2,18 @@ import { StrictMode } from 'react'
|
|||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { OverlayScrollbars } from 'overlayscrollbars'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
// body 전체에 오버레이 스크롤바 적용 (화면을 밀지 않음)
|
||||
OverlayScrollbars(
|
||||
{ target: document.body, cancel: { nativeScrollbarsOverlaid: true } },
|
||||
{
|
||||
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
|
||||
}
|
||||
)
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue