Compare commits

...

9 commits

Author SHA1 Message Date
e418e651b8 관리자 이미지 그리드 열 수 증가 및 미리보기 픽셀 렌더링
- 그리드를 3/4/6열로 늘려 더 많은 이미지를 한 번에 표시
- 이미지 카드에 image-rendering: pixelated + w/h-full로 픽셀 아트 선명하게 표시

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:54:48 +09:00
61822345bf 레이아웃/스크롤바 개선
- Layout 배경 그라디언트를 body fixed 배경으로 이동 (스크롤 시 하단 배경 일관성)
- 해방 계산기 데스티니 탭 placeholder에 최소 높이 부여
- overlayscrollbars 도입: 메인 스크롤바가 콘텐츠를 밀지 않고 오버레이로 표시
- 내부 스크롤 영역은 얇은 커스텀 스크롤바 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 13:35:39 +09:00
85f2d9c482 해방 계산기 추가 개선
- 해방 종류 탭(제네시스/데스티니) 상단에 추가, 데스티니는 구현 예정 안내
- 주간 보스 설정 탭 분리 (단순 계산 / 주차별 계산, 주차별은 준비 중)
- ConfirmDialog 디자인 개편 (아이콘 배지, 큰 타이틀/본문, 프레이머 모션 애니메이션)
- Select/QuestSelector 드롭다운 열림/닫힘 애니메이션
- 해방 계산기 페이지 풀스크린(푸터 숨김)
- 공개 이미지 조회 API(/api/images/:name) 추가
- 현재 진행 상태 섹션 컬럼 폭 조정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 12:13:34 +09:00
1163f77266 해방 완료일 시뮬레이션 기반 로직으로 교체
포인트 이월(캐스케이드) 및 주간/월간 리셋을 정확히 반영하기 위해
weeksNeeded 공식 대신 이벤트 시뮬레이션으로 완료일을 계산.

- 시작일 당일: (주간 - 완료된 주간 몫) + (이번 달 월간, 검은 마법사 미완료 시)
- 이후 매주 목요일에 주간, 매월 1일에 월간 적립
- 누적이 잔여 흔적을 처음 넘는 이벤트 날짜가 해방일

메이플로드/츄츄지지 계산기 결과와 동일하게 동작함을 확인.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 11:52:44 +09:00
8eaf27d143 해방 주간 보스 설정 + 완료일 계산 로직
- 주간 보스 설정 카드: 보스별 난이도/파티/완료 토글, '격파 불가' 옵션
- 주간/월간 획득 포인트 분리 표시
- 완료일 계산: 시작일 주를 1주차로 포함, 매주 목요일 리셋 기준
- 공식: ceil((남은 흔적 + 완료 보스 포인트) / 주간 획득) + 월간 보스 달력 월(1일) 리셋 반영
- 전체 초기화 버튼
- 보스 이름 파일 경로 수정 (진 힐라, 검은 마법사 띄어쓰기)
- 보스 순서 수정 (더스크 → 진 힐라 → 듄켈)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 09:54:06 +09:00
f0c0ea3c1c 해방 계산기 현재 상태 입력 섹션 추가
- 시작 날짜 / 진행 중 퀘스트 / 현재 흔적 입력 카드
- 퀘스트 선택 드롭다운을 일반 보스 초상화 + 텍스트로 단순화
- 각 퀘스트별 최대 3000 흔적 누적 (다음 퀘스트로 자동 이월 안 함)
- 날짜 유틸을 dayjs(KST) 기반으로 통일

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:55:39 +09:00
f27c46f68d 캐릭터 카드 드래그 핸들 영역 제한
카드 전체가 드래그 리스너를 잡고 있어 태블릿에서 세로 스크롤이
불가능했던 문제 해결. useDragControls로 왼쪽 핸들 영역에서만
드래그가 시작되도록 변경.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:06:58 +09:00
0c6ccecc90 해방 진행도 UI 세부 조정
- 라벨/밑줄과 세그먼트 바 간격 조정
- 1차/2차 밑줄 사이 간격을 세그먼트 바와 동일하게 맞춤
- 세그먼트 진행도 색상을 인라인 스타일로 변경 (Tailwind 컴파일 이슈 회피)
- 예상 해방 날짜 표시 위치/스타일 다듬기

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:26:44 +09:00
f7b1c629f9 해방 계산기 진행도 UI 초안 추가
- 제네시스 8챕터 세그먼트 바 + 보스 초상화
- 1차/2차 해방 구분, 예상 해방 날짜 표시
- 다크 테마 커스텀 DatePicker 컴포넌트 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 00:22:46 +09:00
19 changed files with 1464 additions and 76 deletions

19
backend/routes/images.js Normal file
View 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;

View file

@ -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) => {

View file

@ -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",

View file

@ -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"

View file

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

View file

@ -0,0 +1,237 @@
import { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
function ChevronIcon({ dir = 'down', size = 16, className = '' }) {
const rotate = { left: 90, right: -90, up: 180, down: 0 }[dir] || 0
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" style={{ transform: `rotate(${rotate}deg)` }} className={className}>
<path d="M6 9L12 15L18 9" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
}
/**
* 다크 테마 커스텀 DatePicker
* @param {string} value - "YYYY-MM-DD"
* @param {function} onChange
* @param {number} minYear
*/
export default function DatePicker({ value, onChange, placeholder = '날짜 선택', minYear = 2020 }) {
const [isOpen, setIsOpen] = useState(false)
const [viewMode, setViewMode] = useState('days')
const [viewDate, setViewDate] = useState(() => (value ? new Date(value) : new Date()))
const ref = useRef(null)
useEffect(() => {
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) {
setIsOpen(false)
setViewMode('days')
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
useEffect(() => { if (value) setViewDate(new Date(value)) }, [value])
const year = viewDate.getFullYear()
const month = viewDate.getMonth()
const firstDay = new Date(year, month, 1).getDay()
const daysInMonth = new Date(year, month + 1, 0).getDate()
const days = []
for (let i = 0; i < firstDay; i++) days.push(null)
for (let i = 1; i <= daysInMonth; i++) days.push(i)
const groupIndex = Math.floor((year - minYear) / 12)
const startYear = minYear + groupIndex * 12
const years = Array.from({ length: 12 }, (_, i) => startYear + i)
const canGoPrevYearRange = startYear > minYear
const stop = (e, cb) => { e.preventDefault(); e.stopPropagation(); cb() }
const prevMonth = () => {
const d = new Date(year, month - 1, 1)
if (d.getFullYear() >= minYear) setViewDate(d)
}
const nextMonth = () => setViewDate(new Date(year, month + 1, 1))
const prevYearRange = () => canGoPrevYearRange && setViewDate(new Date(startYear - 12, month, 1))
const nextYearRange = () => setViewDate(new Date(startYear + 12, month, 1))
const selectDate = (day) => {
const s = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`
onChange(s)
setIsOpen(false)
setViewMode('days')
}
const selectYear = (y) => setViewDate(new Date(y, month, 1))
const selectMonth = (m) => { setViewDate(new Date(year, m, 1)); setViewMode('days') }
const formatDisplay = (s) => {
if (!s) return ''
const [y, m, d] = s.split('-')
return `${y}${parseInt(m)}${parseInt(d)}`
}
const isSelected = (day) => {
if (!value || !day) return false
const [y, m, d] = value.split('-')
return parseInt(y) === year && parseInt(m) === month + 1 && parseInt(d) === day
}
const isToday = (day) => {
if (!day) return false
const t = new Date()
return t.getFullYear() === year && t.getMonth() === month && t.getDate() === day
}
const currentYear = new Date().getFullYear()
const currentMonth = new Date().getMonth()
const monthNames = ['1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={(e) => stop(e, () => setIsOpen(!isOpen))}
className={`w-full h-12 rounded-lg border bg-gray-950 px-4 text-base flex items-center justify-between transition ${
isOpen ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
}`}
>
<span className={value ? 'text-white' : 'text-gray-500'}>
{value ? formatDisplay(value) : placeholder}
</span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-gray-400">
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: -6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.15 }}
className="absolute z-50 mt-2 left-0 rounded-xl border border-white/10 bg-gray-900 shadow-2xl p-5"
style={{ width: 420 }}
>
<div className="flex items-center justify-between mb-3">
<button
type="button"
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
className="p-1.5 rounded hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed text-gray-400"
>
<ChevronIcon dir="left" size={18} />
</button>
<button
type="button"
onClick={(e) => stop(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))}
className="flex items-center gap-1 text-sm font-medium text-gray-200 hover:text-emerald-300 transition"
>
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}${month + 1}`}
<ChevronIcon dir={viewMode !== 'days' ? 'up' : 'down'} size={14} className="transition-transform" />
</button>
<button
type="button"
onClick={(e) => stop(e, viewMode === 'years' ? nextYearRange : nextMonth)}
className="p-1.5 rounded hover:bg-white/5 text-gray-400"
>
<ChevronIcon dir="right" size={18} />
</button>
</div>
<AnimatePresence mode="wait">
{viewMode === 'years' ? (
<motion.div key="years" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
<div className="text-center text-xs text-gray-500 mb-2">연도</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px', marginBottom: '12px' }}>
{years.map((y) => (
<button
key={y}
type="button"
onClick={(e) => stop(e, () => selectYear(y))}
className={`py-2 rounded-lg text-sm transition ${
year === y
? 'bg-emerald-500 text-white'
: currentYear === y
? 'text-emerald-300 hover:bg-white/5'
: 'text-gray-300 hover:bg-white/5'
}`}
>
{y}
</button>
))}
</div>
<div className="text-center text-xs text-gray-500 mb-2"></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px' }}>
{monthNames.map((m, i) => (
<button
key={m}
type="button"
onClick={(e) => stop(e, () => selectMonth(i))}
className={`py-2 rounded-lg text-sm transition ${
month === i
? 'bg-emerald-500 text-white'
: (currentYear === year && currentMonth === i)
? 'text-emerald-300 hover:bg-white/5'
: 'text-gray-300 hover:bg-white/5'
}`}
>
{m}
</button>
))}
</div>
</motion.div>
) : (
<motion.div key="days" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: '6px', marginBottom: '8px' }}>
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
<div
key={d}
className={`text-center text-xs font-medium py-1 ${
i === 0 ? 'text-red-400/80' : i === 6 ? 'text-sky-400/80' : 'text-gray-500'
}`}
>
{d}
</div>
))}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7, minmax(0, 1fr))', gap: '6px' }}>
{days.map((day, i) => {
const dw = i % 7
const selected = isSelected(day)
const today = isToday(day)
return (
<button
key={i}
type="button"
disabled={!day}
onClick={(e) => day && stop(e, () => selectDate(day))}
style={{ aspectRatio: '1 / 1' }}
className={`rounded-full text-base font-medium flex items-center justify-center transition-all
${!day ? '' : 'hover:bg-white/5'}
${selected ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}
${today && !selected ? 'text-emerald-300 font-bold' : ''}
${day && !selected && !today && dw === 0 ? 'text-red-400' : ''}
${day && !selected && !today && dw === 6 ? 'text-sky-400' : ''}
${day && !selected && !today && dw > 0 && dw < 6 ? 'text-gray-300' : ''}
`}
>
{day}
</button>
)
})}
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View file

@ -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">

View file

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

View file

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

View file

@ -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" />

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

View file

@ -0,0 +1,45 @@
import { useState, useEffect } from 'react'
/**
* 포인트 입력 (3자리 쉼표, 최대 4자리, 0이면 포커스 지움)
*/
export default function PointsInput({ value, onChange, max = 9999, className = '', ...rest }) {
const [text, setText] = useState(() => (value > 0 ? value.toLocaleString() : '0'))
useEffect(() => {
setText(value > 0 ? value.toLocaleString() : '0')
}, [value])
const handleChange = (e) => {
let digits = e.target.value.replace(/[^\d]/g, '')
if (digits.length > String(max).length) digits = digits.slice(0, String(max).length)
const n = digits ? Math.min(Number(digits), max) : 0
setText(n > 0 ? n.toLocaleString() : digits === '' ? '' : '0')
onChange(n)
}
const handleFocus = (e) => {
// 0
if (value === 0 || text === '0') {
setText('')
}
e.target.select()
}
const handleBlur = () => {
if (text === '' || text === '0') setText('0')
}
return (
<input
type="text"
inputMode="numeric"
value={text}
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
className={className}
{...rest}
/>
)
}

View file

@ -0,0 +1,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>
)
}

View file

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

View file

@ -0,0 +1,134 @@
import Select from '../../../components/Select'
import Checkbox from '../../../components/Checkbox'
import Tooltip from '../../../components/Tooltip'
import { WEEKLY_BOSSES, MONTHLY_BOSSES, BOSS_IMAGE_BASE, calcPoints, formatDate } from '../data'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
/**
* week: { startDate, bosses: { [bossKey]: { enabled, difficulty, party } }, includeBlackMage: {enabled, difficulty, party} }
*/
export default function WeekCard({ weekNumber, weekData, cumulativePoints, currentChapter, chapterInfo, onChange, weekProgress }) {
const totalThisWeek = weekProgress.points
const updateBoss = (bossKey, patch) => {
const nextBosses = { ...weekData.bosses, [bossKey]: { ...weekData.bosses[bossKey], ...patch } }
onChange({ ...weekData, bosses: nextBosses })
}
const updateBlackMage = (patch) => {
onChange({ ...weekData, blackMage: { ...weekData.blackMage, ...patch } })
}
return (
<div className="rounded-xl border border-white/5 bg-gray-900/40 overflow-hidden">
{/* 헤더: 주차 번호 + 날짜 + 이번 주 획득 + 누적 */}
<div className="flex items-center justify-between gap-3 px-4 py-3 bg-gray-950/60 border-b border-white/5">
<div className="flex items-center gap-3">
<div className="text-sm font-semibold">{weekNumber}주차</div>
<div className="text-xs text-gray-500">{formatDate(weekData.startDate)}</div>
</div>
<div className="flex items-center gap-4 text-sm">
<div className="text-gray-400">
획득 <span className="text-emerald-300 font-semibold tabular-nums">+{totalThisWeek}</span>
</div>
<div className="text-gray-400">
누적 <span className="text-white font-semibold tabular-nums">{cumulativePoints}</span>
</div>
{chapterInfo && (
<div className="text-xs text-gray-500">
{chapterInfo.name} {chapterInfo.current}/{chapterInfo.required}
</div>
)}
</div>
</div>
{/* 보스 그리드 */}
<div className="p-3 grid grid-cols-2 sm:grid-cols-4 gap-2">
{WEEKLY_BOSSES.map((boss) => {
const sel = weekData.bosses[boss.key] || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
return (
<div key={boss.key} className={`rounded-lg border p-2 transition ${sel.enabled ? 'border-white/10 bg-gray-950/40' : 'border-white/5 bg-transparent opacity-60'}`}>
<div className="flex items-center gap-2 mb-2">
<Checkbox
checked={sel.enabled}
onChange={(v) => updateBoss(boss.key, { enabled: v })}
size="sm"
/>
<Tooltip text={boss.name}>
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
</Tooltip>
<span className="text-xs font-medium truncate flex-1">{boss.name}</span>
{earned > 0 && (
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
)}
</div>
{sel.enabled && (
<div className="flex gap-1.5">
<Select
value={sel.difficulty}
onChange={(v) => updateBoss(boss.key, { difficulty: v })}
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
className="flex-1"
/>
<Select
value={sel.party}
onChange={(v) => updateBoss(boss.key, { party: v })}
options={PARTY_OPTIONS}
className="w-16"
/>
</div>
)}
</div>
)
})}
{/* 검은 마법사 (월 1회) */}
{MONTHLY_BOSSES.map((boss) => {
const sel = weekData.blackMage || { enabled: false, difficulty: boss.difficulties[0].key, party: 1 }
const diff = boss.difficulties.find((d) => d.key === sel.difficulty) || boss.difficulties[0]
const earned = sel.enabled ? calcPoints(diff.points, sel.party) : 0
return (
<div key={boss.key} className={`rounded-lg border p-2 transition col-span-2 sm:col-span-2 ${
sel.enabled ? 'border-amber-500/40 bg-amber-500/[0.05]' : 'border-white/5 bg-transparent opacity-60'
}`}>
<div className="flex items-center gap-2 mb-2">
<Checkbox
checked={sel.enabled}
onChange={(v) => updateBlackMage({ enabled: v })}
size="sm"
/>
<Tooltip text={`${boss.name} (월 1회)`}>
<img src={`${BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-7 h-7 rounded object-cover" />
</Tooltip>
<span className="text-xs font-medium flex-1">{boss.name} <span className="text-[10px] text-amber-400">월간</span></span>
{earned > 0 && (
<span className="text-xs text-emerald-300 font-semibold tabular-nums">+{earned}</span>
)}
</div>
{sel.enabled && (
<div className="flex gap-1.5">
<Select
value={sel.difficulty}
onChange={(v) => updateBlackMage({ difficulty: v })}
options={boss.difficulties.map((d) => ({ value: d.key, label: `${d.label} +${d.points}` }))}
className="flex-1"
/>
<Select
value={sel.party}
onChange={(v) => updateBlackMage({ party: v })}
options={PARTY_OPTIONS}
className="w-16"
/>
</div>
)}
</div>
)
})}
</div>
</div>
)
}

View file

@ -0,0 +1,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>
)
}

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

View file

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

View file

@ -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: {