해방 계산기 추가 개선

- 해방 종류 탭(제네시스/데스티니) 상단에 추가, 데스티니는 구현 예정 안내
- 주간 보스 설정 탭 분리 (단순 계산 / 주차별 계산, 주차별은 준비 중)
- ConfirmDialog 디자인 개편 (아이콘 배지, 큰 타이틀/본문, 프레이머 모션 애니메이션)
- Select/QuestSelector 드롭다운 열림/닫힘 애니메이션
- 해방 계산기 페이지 풀스크린(푸터 숨김)
- 공개 이미지 조회 API(/api/images/:name) 추가
- 현재 진행 상태 섹션 컬럼 폭 조정

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-14 12:13:34 +09:00
parent 1163f77266
commit 85f2d9c482
7 changed files with 270 additions and 95 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

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

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

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

View file

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

View file

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