diff --git a/backend/routes/images.js b/backend/routes/images.js
new file mode 100644
index 0000000..8bad0fd
--- /dev/null
+++ b/backend/routes/images.js
@@ -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;
diff --git a/backend/server.js b/backend/server.js
index 7f676dc..25cd9e5 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -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) => {
diff --git a/frontend/src/components/ConfirmDialog.jsx b/frontend/src/components/ConfirmDialog.jsx
index 7fc49b0..d7d2e02 100644
--- a/frontend/src/components/ConfirmDialog.jsx
+++ b/frontend/src/components/ConfirmDialog.jsx
@@ -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 (
-
-
e.stopPropagation()}>
-
-
{title}
- ×
-
-
-
-
+ {open && (
+
+ e.stopPropagation()}
>
- {cancelText}
-
-
- {loading ? '처리 중...' : confirmText}
-
-
-
-
+
+
+ {destructive ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
{title}
+
+ ×
+
+
+
+
+
+ {cancelText}
+
+
+ {loading ? '처리 중...' : confirmText}
+
+
+
+
+ )}
+
)
}
diff --git a/frontend/src/components/Select.jsx b/frontend/src/components/Select.jsx
index 2b8082d..b574000 100644
--- a/frontend/src/components/Select.jsx
+++ b/frontend/src/components/Select.jsx
@@ -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 =
- {open && (
-
-
- {options.map((opt) => (
-
{ 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 && (
-
-
-
- )}
- {opt.label}
-
- ))}
-
-
- )}
+
+ {open && (
+
+
+ {options.map((opt) => (
+
{ 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 && (
+
+
+
+ )}
+ {opt.label}
+
+ ))}
+
+
+ )}
+
)
}
diff --git a/frontend/src/features/liberation/Liberation.jsx b/frontend/src/features/liberation/Liberation.jsx
index 15f9d40..11fcfd4 100644
--- a/frontend/src/features/liberation/Liberation.jsx
+++ b/frontend/src/features/liberation/Liberation.jsx
@@ -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 (
+ {/* 해방 종류 탭 */}
+
+ {[
+ { key: 'genesis', label: '제네시스 해방', img: genesisImg.data?.url },
+ { key: 'destiny', label: '데스티니 해방', img: destinyImg.data?.url },
+ ].map((tab) => (
+
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 && }
+ {tab.label}
+
+ ))}
+
+
+ {liberationType === 'destiny' ? (
+
+
구현 예정
+
데스티니 해방 계산기는 준비 중입니다.
+
+ ) : (<>
현재 진행 상태
-
+
시작 날짜
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"
>
@@ -318,6 +373,17 @@ export default function Liberation() {
전체 초기화
+ >)}
+
+
setResetOpen(false)}
+ onConfirm={doReset}
+ title="전체 초기화"
+ description={'입력한 내용을 모두 초기화하시겠습니까?\n\n시작 날짜, 현재 진행 상태, 주간 보스 설정이 모두 초기값으로 되돌아갑니다.'}
+ confirmText="초기화"
+ destructive
+ />
)
}
diff --git a/frontend/src/features/liberation/components/QuestSelector.jsx b/frontend/src/features/liberation/components/QuestSelector.jsx
index c547768..e47dbcd 100644
--- a/frontend/src/features/liberation/components/QuestSelector.jsx
+++ b/frontend/src/features/liberation/components/QuestSelector.jsx
@@ -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 }) {
- {open && (
-
+
+ {open && (
+
{GENESIS_CHAPTERS.map((chapter) => {
const isSelected = chapter.idx === value
return (
@@ -75,8 +83,9 @@ export default function QuestSelector({ value, onChange }) {
)
})}
-
- )}
+
+ )}
+
)
}
diff --git a/frontend/src/features/liberation/components/WeeklyDefault.jsx b/frontend/src/features/liberation/components/WeeklyDefault.jsx
index 318bad5..8da34a3 100644
--- a/frontend/src/features/liberation/components/WeeklyDefault.jsx
+++ b/frontend/src/features/liberation/components/WeeklyDefault.jsx
@@ -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 (
-
+
@@ -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 (
-
+
주간 보스 설정
-
-
- 주간 획득 +{totalWeekly}
-
-
- 월간 획득 +{totalMonthly}
-
+
+ setMode('simple')}>단순 계산
+ setMode('weekly')}>주차별 계산
-
- {WEEKLY_BOSSES.map((boss) => (
- updateBoss(boss.key, patch)}
- />
- ))}
- {MONTHLY_BOSSES.map((boss) => (
-
- ))}
-
+ {mode === 'simple' ? (
+ <>
+
+
+ 주간 획득 +{totalWeekly}
+
+
+ 월간 획득 +{totalMonthly}
+
+
+
+ {WEEKLY_BOSSES.map((boss) => (
+ updateBoss(boss.key, patch)}
+ />
+ ))}
+ {MONTHLY_BOSSES.map((boss) => (
+
+ ))}
+
+ >
+ ) : (
+
+ 주차별 계산 UI 준비 중
+
+ )}
)
}
+
+function TabButton({ active, onClick, children }) {
+ return (
+
+ {children}
+
+ )
+}