헤더/탭 제목, 커스텀 툴팁, 캐릭터 입력 UI 개선

- 커스텀 Tooltip 컴포넌트 (portal, fadeIn, 자동 위치 보정)
- 헤더에 현재 메뉴 제목 표시 (브레드크럼 스타일)
- 브라우저 탭 제목 자동 동기화
- 캐릭터 닉네임 검색 입력 강조 (아이콘, 두꺼운 테두리, 그림자)
- 결정 개수 강조 (큰 폰트, amber 색상)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-13 20:38:01 +09:00
parent b980e7d78a
commit 0fb325b815
6 changed files with 251 additions and 70 deletions

View file

@ -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 (
<div className="flex items-center gap-3 text-gray-400">
<span className="text-white/20">/</span>
<div className="flex items-center gap-2">
{menu.image?.url && (
<img src={menu.image.url} alt="" className="w-5 h-5 object-contain" />
)}
<span className="text-sm font-medium text-gray-200">{menu.title}</span>
</div>
</div>
)
}
export default function Layout() {
const [fullscreen, setFullscreen] = useState(false)
@ -18,12 +62,15 @@ export default function Layout() {
}`}>
<header className="sticky top-0 z-20 border-b border-white/5 bg-gray-950/80 backdrop-blur-md shrink-0">
<div className="mx-auto flex max-w-[1400px] items-center justify-between px-6 py-4">
<Link to="/" className="group flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-8 h-8" />
<span className="text-lg font-bold tracking-tight">
메이플스토리 유틸리티
</span>
</Link>
<div className="flex items-center gap-3">
<Link to="/" className="group flex items-center gap-2.5">
<img src="/favicon.ico" alt="" className="w-8 h-8" />
<span className="text-lg font-bold tracking-tight">
메이플스토리 유틸리티
</span>
</Link>
<CurrentMenuTitle />
</div>
</div>
</header>
<main className={`flex-1 mx-auto w-full max-w-[1400px] ${

View file

@ -0,0 +1,110 @@
import { useState, useRef, useEffect, useLayoutEffect } from 'react'
import { createPortal } from 'react-dom'
/**
* 커스텀 툴팁
* <Tooltip text="설명">
* <button>...</button>
* </Tooltip>
*/
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 (
<>
<span
ref={triggerRef}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
onFocus={handleEnter}
onBlur={handleLeave}
className="inline-block"
>
{children}
</span>
{open && createPortal(
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
zIndex: 9999,
opacity: coords ? 1 : 0,
transition: 'opacity 120ms ease-out',
}}
className="pointer-events-none px-2 py-1 rounded-md bg-gray-900 border border-white/10 text-xs text-gray-200 shadow-lg whitespace-nowrap"
>
{text}
</div>,
document.body
)}
</>
)
}

View file

@ -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 (
<span
key={d.key}
className="text-[10px] font-medium px-1.5 py-0.5 rounded border"
style={getDifficultyBadgeStyle(d.key)}
title={`${d.label} - ${formatMeso(bd.crystal_price)}`}
>
{d.label}
</span>
<Tooltip key={d.key} text={`${d.label} · ${formatMeso(bd.crystal_price)}`}>
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded border"
style={getDifficultyBadgeStyle(d.key)}
>
{d.label}
</span>
</Tooltip>
)
})}
</div>

View file

@ -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 (
<button
key={d.key}
type="button"
tabIndex={-1}
onClick={(e) => {
e.currentTarget.blur()
if (active) {
onChange(boss.id, null)
} else {
onChange(boss.id, { difficulty: d.key, party: partyN })
}
}}
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
title={d.label}
>
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
</button>
<Tooltip key={d.key} text={d.label}>
<button
type="button"
tabIndex={-1}
onClick={(e) => {
e.currentTarget.blur()
if (active) {
onChange(boss.id, null)
} else {
onChange(boss.id, { difficulty: d.key, party: partyN })
}
}}
className={`shrink-0 transition focus:outline-none ${active ? 'opacity-100 scale-105' : 'opacity-40 hover:opacity-70'}`}
>
<img src={getDifficultyImageUrl(d.key)} alt={d.label} className="h-5" />
</button>
</Tooltip>
)
})}
</div>

View file

@ -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 (
<div
<Tooltip
key={item.boss.id}
className="space-y-0.5"
title={`${item.boss.name} ${diff?.label || ''} - ${formatMeso(item.revenue)}`}
text={`${item.boss.name} ${diff?.label || ''} · ${formatMeso(item.revenue)}`}
>
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
</div>
<div className="flex justify-center">
<div
className="text-[9px] font-bold leading-none rounded border w-3.5 h-3.5 flex items-center justify-center"
style={getDifficultyBadgeStyle(item.difficulty)}
>
{diff?.initial}
<div className="space-y-0.5">
<div className="aspect-square rounded bg-gray-900 overflow-hidden border border-white/5">
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} className="w-full h-full object-cover select-none" />
</div>
<div className="flex justify-center">
<div
className="text-[9px] font-bold leading-none rounded border w-3.5 h-3.5 flex items-center justify-center"
style={getDifficultyBadgeStyle(item.difficulty)}
>
{diff?.initial}
</div>
</div>
</div>
</div>
</Tooltip>
)
})}
</div>
@ -85,8 +87,9 @@ function CharacterContent({ char, selections, bosses }) {
</div>
<div className="flex items-center justify-between border-t border-white/5 pt-2">
<div className={`text-sm tabular-nums ${count > 0 ? 'text-gray-400' : 'text-gray-600'}`}>
{count}<span className="text-gray-700">/{MAX_PER_CHARACTER}</span>
<div className="flex items-baseline gap-1 tabular-nums">
<span className={`text-base font-bold ${count > 0 ? 'text-amber-300' : 'text-gray-600'}`}>{count}</span>
<span className="text-base font-bold text-amber-300/40">/ {MAX_PER_CHARACTER}</span>
</div>
<div className={`text-sm font-semibold tabular-nums whitespace-nowrap ${count > 0 ? 'text-emerald-300' : 'text-gray-700'}`}>
{count > 0 ? formatMeso(totalRevenue) : '-'}
@ -221,39 +224,52 @@ export default function CharacterPanel({
</div>
</div>
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-gray-400"> 결정 개수</span>
<span className={`tabular-nums font-semibold ${totalCount > MAX_PER_ACCOUNT ? 'text-amber-400' : 'text-gray-200'}`}>
{accountUsage}<span className="text-gray-600 font-normal">/{MAX_PER_ACCOUNT}</span>
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-2">
<div className="text-sm text-gray-400"> 결정 개수</div>
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
<div
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${usagePct}%` }}
/>
</div>
</div>
<div className="flex items-baseline gap-1 tabular-nums">
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400' : 'text-amber-300'}`}>
{accountUsage}
</span>
<span className={`text-2xl font-bold leading-none ${totalCount > MAX_PER_ACCOUNT ? 'text-red-400/40' : 'text-amber-300/40'}`}>
/ {MAX_PER_ACCOUNT}
</span>
</div>
<div className="h-2 rounded-full bg-gray-900 overflow-hidden">
<div
className={`h-full transition-all ${totalCount > MAX_PER_ACCOUNT ? 'bg-amber-500' : 'bg-emerald-500'}`}
style={{ width: `${usagePct}%` }}
/>
</div>
{totalCount > MAX_PER_ACCOUNT && (
<p className="text-[10px] text-amber-400"> 한도 {totalCount - MAX_PER_ACCOUNT} 초과</p>
)}
</div>
{totalCount > MAX_PER_ACCOUNT && (
<p className="text-[10px] text-amber-400"> 한도 {totalCount - MAX_PER_ACCOUNT} 초과</p>
)}
</div>
{/* 캐릭터 추가 (고정) */}
<div className="shrink-0">
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="text"
value={name}
onChange={(e) => { 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"
/>
<div className="relative flex-1 min-w-0">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 pointer-events-none">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<circle cx="6.5" cy="6.5" r="4.5" stroke="currentColor" strokeWidth="1.5" />
<path d="M10 10L14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</span>
<input
type="text"
value={name}
onChange={(e) => { 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"
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending}
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-4 py-2 text-sm font-medium transition shrink-0"
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 px-5 py-2.5 text-sm font-medium transition shrink-0 shadow-lg shadow-emerald-500/20"
>
{searchMutation.isPending ? '...' : '추가'}
</button>

View file

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