헤더/탭 제목, 커스텀 툴팁, 캐릭터 입력 UI 개선
- 커스텀 Tooltip 컴포넌트 (portal, fadeIn, 자동 위치 보정) - 헤더에 현재 메뉴 제목 표시 (브레드크럼 스타일) - 브라우저 탭 제목 자동 동기화 - 캐릭터 닉네임 검색 입력 강조 (아이콘, 두꺼운 테두리, 그림자) - 결정 개수 강조 (큰 폰트, amber 색상) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b980e7d78a
commit
0fb325b815
6 changed files with 251 additions and 70 deletions
|
|
@ -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] ${
|
||||
|
|
|
|||
110
frontend/src/components/Tooltip.jsx
Normal file
110
frontend/src/components/Tooltip.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue