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}
+
+
+

+
+
-
+
)
})}
@@ -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}개 초과
+ )}
{/* 캐릭터 추가 (고정) */}