From 85f2d9c482a925394958c93911d79de77f6dd62c Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 14 Apr 2026 12:13:34 +0900 Subject: [PATCH] =?UTF-8?q?=ED=95=B4=EB=B0=A9=20=EA=B3=84=EC=82=B0?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 해방 종류 탭(제네시스/데스티니) 상단에 추가, 데스티니는 구현 예정 안내 - 주간 보스 설정 탭 분리 (단순 계산 / 주차별 계산, 주차별은 준비 중) - ConfirmDialog 디자인 개편 (아이콘 배지, 큰 타이틀/본문, 프레이머 모션 애니메이션) - Select/QuestSelector 드롭다운 열림/닫힘 애니메이션 - 해방 계산기 페이지 풀스크린(푸터 숨김) - 공개 이미지 조회 API(/api/images/:name) 추가 - 현재 진행 상태 섹션 컬럼 폭 조정 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/routes/images.js | 19 ++++ backend/server.js | 2 + frontend/src/components/ConfirmDialog.jsx | 101 ++++++++++++------ frontend/src/components/Select.jsx | 63 ++++++----- .../src/features/liberation/Liberation.jsx | 74 ++++++++++++- .../liberation/components/QuestSelector.jsx | 17 ++- .../liberation/components/WeeklyDefault.jsx | 89 +++++++++------ 7 files changed, 270 insertions(+), 95 deletions(-) create mode 100644 backend/routes/images.js 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}

- -
-
-

{description}

-
-
- - -
-
-
+
+
+ {destructive ? ( + + + + ) : ( + + + + )} +
+

{title}

+ +
+
+

{description}

+
+
+ + +
+ + + )} + ) } 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) => ( - - ))} -
-
- )} + + {open && ( + +
+ {options.map((opt) => ( + + ))} +
+
+ )} +
) } 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) => ( + + ))} +
+ + {liberationType === 'destiny' ? ( +
+
구현 예정
+
데스티니 해방 계산기는 준비 중입니다.
+
+ ) : (<>
현재 진행 상태
-
+
+ )} + + 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 ( + + ) +}