해방 계산기 추가 개선
- 해방 종류 탭(제네시스/데스티니) 상단에 추가, 데스티니는 구현 예정 안내 - 주간 보스 설정 탭 분리 (단순 계산 / 주차별 계산, 주차별은 준비 중) - ConfirmDialog 디자인 개편 (아이콘 배지, 큰 타이틀/본문, 프레이머 모션 애니메이션) - Select/QuestSelector 드롭다운 열림/닫힘 애니메이션 - 해방 계산기 페이지 풀스크린(푸터 숨김) - 공개 이미지 조회 API(/api/images/:name) 추가 - 현재 진행 상태 섹션 컬럼 폭 조정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1163f77266
commit
85f2d9c482
7 changed files with 270 additions and 95 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) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
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,
|
||||
|
|
@ -16,6 +18,8 @@ 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'
|
||||
|
||||
|
|
@ -70,6 +74,25 @@ function calcMonthlyDoneEarn(weekData) {
|
|||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -77,6 +100,7 @@ export default function Liberation() {
|
|||
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
|
||||
|
|
@ -98,6 +122,7 @@ export default function Liberation() {
|
|||
currentPoints: 0,
|
||||
startDate: dayjs(todayKST()).toISOString(),
|
||||
weekly: makeEmptyWeekly(),
|
||||
weekOverrides: {},
|
||||
weeks: [makeEmptyWeek(todayKST())],
|
||||
}
|
||||
})
|
||||
|
|
@ -233,15 +258,17 @@ export default function Liberation() {
|
|||
setState((prev) => ({ ...prev, weeks: prev.weeks.filter((_, i) => i !== idx) }))
|
||||
}
|
||||
|
||||
const resetAll = () => {
|
||||
if (!confirm('입력한 내용을 모두 초기화하시겠습니까?')) return
|
||||
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) => {
|
||||
|
|
@ -260,6 +287,34 @@ export default function Liberation() {
|
|||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 해방 종류 탭 */}
|
||||
<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">
|
||||
<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}
|
||||
|
|
@ -270,7 +325,7 @@ export default function Liberation() {
|
|||
<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: '1fr 1.2fr 1fr' }}>
|
||||
<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
|
||||
|
|
@ -309,7 +364,7 @@ export default function Liberation() {
|
|||
<div className="max-w-2xl mx-auto flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={resetAll}
|
||||
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">
|
||||
|
|
@ -318,6 +373,17 @@ export default function Liberation() {
|
|||
전체 초기화
|
||||
</button>
|
||||
</div>
|
||||
</>)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={resetOpen}
|
||||
onClose={() => setResetOpen(false)}
|
||||
onConfirm={doReset}
|
||||
title="전체 초기화"
|
||||
description={'입력한 내용을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.'}
|
||||
confirmText="초기화"
|
||||
destructive
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { GENESIS_CHAPTERS, QUEST_BOSS_IMAGE_BASE } from '../data'
|
||||
|
||||
/**
|
||||
|
|
@ -47,8 +48,15 @@ export default function QuestSelector({ value, onChange }) {
|
|||
</svg>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div 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">
|
||||
<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 (
|
||||
|
|
@ -75,8 +83,9 @@ export default function QuestSelector({ value, onChange }) {
|
|||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
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'
|
||||
|
|
@ -17,13 +18,11 @@ function diffLabel(d, party) {
|
|||
|
||||
function BossRow({ boss, sel, onChange, monthly = false }) {
|
||||
const disabled = sel.difficulty === 'none'
|
||||
const rowStyle = ''
|
||||
|
||||
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 ${rowStyle}`}>
|
||||
<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>
|
||||
|
|
@ -69,6 +68,8 @@ function BossRow({ boss, sel, onChange, monthly = false }) {
|
|||
}
|
||||
|
||||
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 } } })
|
||||
}
|
||||
|
|
@ -78,37 +79,65 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
|||
|
||||
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-baseline justify-between">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
||||
<div className="text-sm text-gray-400 flex items-baseline 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 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>
|
||||
|
||||
<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>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue