diff --git a/frontend/src/components/Layout.jsx b/frontend/src/components/Layout.jsx index e1b82a0..ef1f26e 100644 --- a/frontend/src/components/Layout.jsx +++ b/frontend/src/components/Layout.jsx @@ -1,13 +1,57 @@ -import { createContext, useContext, useState } from 'react' -import { Outlet, Link } from 'react-router-dom' +import { createContext, useContext, useState, useEffect } from 'react' +import { Outlet, Link, useLocation } from 'react-router-dom' +import { useQuery } from '@tanstack/react-query' +import { api } from '../api/client' import Footer from './Footer' +const SITE_NAME = '메이플스토리 유틸리티' + const LayoutContext = createContext({ setFullscreen: () => {} }) export function useLayout() { return useContext(LayoutContext) } +function CurrentMenuTitle() { + const location = useLocation() + const { data: menus = [] } = useQuery({ + queryKey: ['menus'], + queryFn: () => api('/api/menus').catch(() => []), + }) + + const path = location.pathname + const slug = path.replace(/^\/+/, '').split('/')[0] + const isAdmin = slug === 'admin' + const menu = (!slug || isAdmin) + ? null + : menus.find((m) => (m.url || '').replace(/^\/+/, '').split('/')[0] === slug) + + // 브라우저 탭 제목 동기화 + useEffect(() => { + if (isAdmin) { + document.title = `관리자 - ${SITE_NAME}` + } else if (menu) { + document.title = `${menu.title} - ${SITE_NAME}` + } else { + document.title = SITE_NAME + } + }, [isAdmin, menu]) + + if (!menu) return null + + return ( +
+ / +
+ {menu.image?.url && ( + + )} + {menu.title} +
+
+ ) +} + export default function Layout() { const [fullscreen, setFullscreen] = useState(false) @@ -18,12 +62,15 @@ export default function Layout() { }`}>
- - - - 메이플스토리 유틸리티 - - +
+ + + + 메이플스토리 유틸리티 + + + +
+ * + * + */ +export default function Tooltip({ text, children, placement = 'top', delay = 200 }) { + const [open, setOpen] = useState(false) + const [coords, setCoords] = useState(null) // null이면 위치 측정 전 (안 보임) + const triggerRef = useRef(null) + const tooltipRef = useRef(null) + const timerRef = useRef(null) + + const updatePosition = () => { + if (!triggerRef.current || !tooltipRef.current) return + const trigger = triggerRef.current.getBoundingClientRect() + const tooltip = tooltipRef.current.getBoundingClientRect() + const gap = 6 + + let top, left + switch (placement) { + case 'bottom': + top = trigger.bottom + gap + left = trigger.left + trigger.width / 2 - tooltip.width / 2 + break + case 'left': + top = trigger.top + trigger.height / 2 - tooltip.height / 2 + left = trigger.left - tooltip.width - gap + break + case 'right': + top = trigger.top + trigger.height / 2 - tooltip.height / 2 + left = trigger.right + gap + break + case 'top': + default: + top = trigger.top - tooltip.height - gap + left = trigger.left + trigger.width / 2 - tooltip.width / 2 + } + + const padding = 4 + left = Math.max(padding, Math.min(left, window.innerWidth - tooltip.width - padding)) + top = Math.max(padding, Math.min(top, window.innerHeight - tooltip.height - padding)) + + setCoords({ top, left }) + } + + // open 상태가 true가 되면 즉시 위치 측정 (paint 전에) + useLayoutEffect(() => { + if (open) updatePosition() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + useEffect(() => { + if (!open) return + const handler = () => updatePosition() + window.addEventListener('scroll', handler, true) + window.addEventListener('resize', handler) + return () => { + window.removeEventListener('scroll', handler, true) + window.removeEventListener('resize', handler) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]) + + const handleEnter = () => { + timerRef.current = setTimeout(() => setOpen(true), delay) + } + const handleLeave = () => { + clearTimeout(timerRef.current) + setOpen(false) + setCoords(null) + } + + if (!text) return children + + return ( + <> + + {children} + + {open && createPortal( +
+ {text} +
, + document.body + )} + + ) +} diff --git a/frontend/src/features/boss-crystal/admin/BossList.jsx b/frontend/src/features/boss-crystal/admin/BossList.jsx index f1e289c..c81121e 100644 --- a/frontend/src/features/boss-crystal/admin/BossList.jsx +++ b/frontend/src/features/boss-crystal/admin/BossList.jsx @@ -11,6 +11,7 @@ import { } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { api } from '../../../api/client' +import Tooltip from '../../../components/Tooltip' import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants' function BossCardContent({ boss, dragging = false }) { @@ -45,14 +46,14 @@ function BossCardContent({ boss, dragging = false }) { {DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => { const bd = boss.difficulties.find((x) => x.difficulty === d.key) return ( - - {d.label} - + + + {d.label} + + ) })} diff --git a/frontend/src/features/boss-crystal/user/BossSelector.jsx b/frontend/src/features/boss-crystal/user/BossSelector.jsx index 45f45f1..4c66c5a 100644 --- a/frontend/src/features/boss-crystal/user/BossSelector.jsx +++ b/frontend/src/features/boss-crystal/user/BossSelector.jsx @@ -1,4 +1,5 @@ import Select from '../../../components/Select' +import Tooltip from '../../../components/Tooltip' import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from '../admin/constants' export default function BossSelector({ characterName, bosses, selections, onChange, maxReached, selectedCount, maxPerCharacter }) { @@ -67,23 +68,23 @@ export default function BossSelector({ characterName, bosses, selections, onChan {availableDiffs.map((d) => { const active = sel?.difficulty === d.key return ( - + + + ) })} diff --git a/frontend/src/features/boss-crystal/user/CharacterPanel.jsx b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx index d2630c9..4cef210 100644 --- a/frontend/src/features/boss-crystal/user/CharacterPanel.jsx +++ b/frontend/src/features/boss-crystal/user/CharacterPanel.jsx @@ -3,6 +3,7 @@ import { useMutation } from '@tanstack/react-query' import { Reorder } from 'framer-motion' import { api } from '../../../api/client' import ConfirmDialog from '../../../components/ConfirmDialog' +import Tooltip from '../../../components/Tooltip' import { useFitText } from '../../../hooks/useFitText' import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants' @@ -58,23 +59,24 @@ function CharacterContent({ char, selections, bosses }) { {visibleBosses.map((item) => { const diff = DIFFICULTIES.find((d) => d.key === item.difficulty) return ( -
-
- -
-
-
- {diff?.initial} +
+
+ +
+
+
+ {diff?.initial} +
-
+ ) })}
@@ -85,8 +87,9 @@ function CharacterContent({ char, selections, bosses }) {
-
0 ? 'text-gray-400' : 'text-gray-600'}`}> - {count}/{MAX_PER_CHARACTER} +
+ 0 ? 'text-amber-300' : 'text-gray-600'}`}>{count} + / {MAX_PER_CHARACTER}
0 ? 'text-emerald-300' : 'text-gray-700'}`}> {count > 0 ? formatMeso(totalRevenue) : '-'} @@ -221,39 +224,52 @@ export default function CharacterPanel({
-
-
- 총 결정 개수 - MAX_PER_ACCOUNT ? 'text-amber-400' : 'text-gray-200'}`}> - {accountUsage}/{MAX_PER_ACCOUNT} +
+
+
총 결정 개수
+
+
MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`} + style={{ width: `${usagePct}%` }} + /> +
+
+
+ MAX_PER_ACCOUNT ? 'text-red-400' : 'text-amber-300'}`}> + {accountUsage} + + MAX_PER_ACCOUNT ? 'text-red-400/40' : 'text-amber-300/40'}`}> + / {MAX_PER_ACCOUNT}
-
-
MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`} - style={{ width: `${usagePct}%` }} - /> -
- {totalCount > MAX_PER_ACCOUNT && ( -

⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과

- )}
+ {totalCount > MAX_PER_ACCOUNT && ( +

⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과

+ )}
{/* 캐릭터 추가 (고정) */}
- { setName(e.target.value); if (error) setError('') }} - placeholder="캐릭터 닉네임 입력" - className="flex-1 min-w-0 rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition" - /> +
+ + + + + + + { setName(e.target.value); if (error) setError('') }} + placeholder="캐릭터 닉네임 검색" + className="w-full rounded-lg border-2 border-white/10 bg-gray-950 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-emerald-500/60 hover:border-white/20 transition" + /> +
diff --git a/frontend/src/index.css b/frontend/src/index.css index e2a8b08..5cce142 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -40,6 +40,12 @@ input[type="number"] { -moz-appearance: textfield; } +/* 툴팁 애니메이션 */ +@keyframes fadeIn { + from { opacity: 0; transform: translateY(2px); } + to { opacity: 1; transform: translateY(0); } +} + /* 커스텀 스크롤바 */ * { scrollbar-width: thin;