maplestory/frontend/src/features/boss-crystal/pc/user/CharacterPanel.jsx
caadiq 281332ad14 보스 이미지 loading=lazy, decoding=async 추가
이전 revert 과정에서 함께 사라졌던 이미지 비동기 로딩 속성 복원.
애니메이션과 무관한 최적화이므로 유지.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 00:49:45 +09:00

424 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { Reorder, useDragControls } from 'framer-motion'
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
import { api } from '../../../../api/client'
import ConfirmDialog from '../../../../components/common/ConfirmDialog'
import Tooltip from '../../../../components/common/Tooltip'
import CharacterSuggestDropdown from '../../../../components/common/CharacterSuggestDropdown'
import { useFitText } from '../../../../hooks/useFitText'
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
const MAX_PER_CHARACTER = 12
const MAX_PER_ACCOUNT = 90
function CharacterContent({ char, selections, bosses }) {
const bossIndex = new Map(bosses.map((b, i) => [b.id, i]))
const selectedBosses = Object.entries(selections || {})
.filter(([, sel]) => sel)
.map(([bossId, sel]) => {
const boss = bosses.find((b) => b.id === Number(bossId))
if (!boss) return null
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
if (!bd) return null
return {
boss,
difficulty: sel.difficulty,
revenue: Math.floor(bd.crystal_price / sel.party),
}
})
.filter(Boolean)
// 12개 상한은 수익 높은 순으로 취한 뒤, 표시는 보스 목록 순서대로 정렬
const topByRevenue = [...selectedBosses].sort((a, b) => b.revenue - a.revenue).slice(0, MAX_PER_CHARACTER)
const visibleBosses = topByRevenue.sort(
(a, b) => (bossIndex.get(a.boss.id) ?? 0) - (bossIndex.get(b.boss.id) ?? 0)
)
const totalRevenue = visibleBosses.reduce((s, x) => s + x.revenue, 0)
const count = selectedBosses.length
return (
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="shrink-0 overflow-hidden flex items-center justify-center" style={{ width: 96, height: 96 }}>
{char.character_image ? (
<img
src={char.character_image}
alt=""
className="w-full h-full object-contain scale-[3] origin-center select-none"
style={{ imageRendering: 'pixelated' }}
draggable={false}
loading="lazy"
decoding="async"
/>
) : (
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
)}
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-base font-semibold truncate">{char.character_name}</span>
<span className="text-xs truncate" style={{ color: 'var(--text-dim)' }}>
Lv.{char.character_level} · {char.job_name}
</span>
</div>
{visibleBosses.length > 0 ? (
<div className="grid grid-cols-6 gap-1.5">
{visibleBosses.map((item) => {
const diff = DIFFICULTIES.find((d) => d.key === item.difficulty)
return (
<Tooltip
key={item.boss.id}
text={`${diff?.label || ''} ${item.boss.name} · ${formatMeso(item.revenue)}`}
>
<div className="space-y-0.5">
<div
className="aspect-square rounded overflow-hidden border"
style={{
background: 'var(--surface-nested)',
borderColor: 'var(--panel-border)',
}}
>
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} loading="lazy" decoding="async" 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>
</Tooltip>
)
})}
</div>
) : (
<div
className="text-xs italic h-[58px] flex items-center"
style={{ color: 'var(--text-dim)' }}
>
보스 미선택
</div>
)}
</div>
</div>
<div
className="flex items-center justify-between border-t pt-2"
style={{ borderColor: 'var(--panel-border)' }}
>
<div className="flex items-baseline gap-1 tabular-nums">
<span
className="text-base font-bold"
style={{ color: count > 0 ? 'var(--warning-text-bright)' : 'var(--text-dim)' }}
>
{count}
</span>
<span
className="text-base font-bold"
style={{ color: count > 0 ? 'var(--warning-text-dim)' : 'var(--text-dim)' }}
>
/ {MAX_PER_CHARACTER}
</span>
</div>
<div
className="text-sm font-semibold tabular-nums whitespace-nowrap"
style={{ color: count > 0 ? 'var(--accent-bright)' : 'var(--text-dim)' }}
>
{count > 0 ? formatMeso(totalRevenue) : '-'}
</div>
</div>
</div>
)
}
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
const [dragged, setDragged] = useState(false)
const dragControls = useDragControls()
return (
<Reorder.Item
value={char}
dragListener={false}
dragControls={dragControls}
onDragStart={() => setDragged(true)}
onDragEnd={() => {
// 다음 click 이벤트 후에 reset
setTimeout(() => setDragged(false), 0)
}}
onClick={(e) => {
if (dragged) return
if (e.target.closest('button')) return
onSelect(char.character_name)
}}
className="group relative rounded-xl border cursor-pointer select-none"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
background: isSelected ? 'var(--selected-bg)' : 'var(--surface-3)',
}}
>
{/* 드래그 핸들 */}
<div
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
className="absolute left-0 top-0 bottom-0 w-8 flex items-center justify-center cursor-grab active:cursor-grabbing"
style={{ touchAction: 'none', color: 'var(--text-dim)' }}
>
<svg width="12" height="16" viewBox="0 0 12 16" fill="currentColor">
<circle cx="3" cy="3" r="1.2" />
<circle cx="9" cy="3" r="1.2" />
<circle cx="3" cy="8" r="1.2" />
<circle cx="9" cy="8" r="1.2" />
<circle cx="3" cy="13" r="1.2" />
<circle cx="9" cy="13" r="1.2" />
</svg>
</div>
<button
type="button"
onClick={(e) => { e.stopPropagation(); onRemove(char) }}
className="absolute top-2 right-2 z-10 w-6 h-6 rounded opacity-0 group-hover:opacity-100 flex items-center justify-center text-base hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)]"
style={{ color: 'var(--text-dim)' }}
aria-label="삭제"
>
×
</button>
<div className="pl-8 pr-3 py-2.5">
<CharacterContent char={char} selections={selections} bosses={bosses} />
</div>
</Reorder.Item>
)
}
export default function CharacterPanel({
characters, selectedName, allSelections, bosses,
onSelect, onAdd, onRemove, onReorder,
}) {
const [name, setName] = useState('')
const [error, setError] = useState('')
const [confirmRemove, setConfirmRemove] = useState(null)
const [dropdownOpen, setDropdownOpen] = useState(false)
const searchMutation = useMutation({
mutationFn: (n) => api(`/api/character/search?name=${encodeURIComponent(n)}`),
onSuccess: (data) => {
if (characters.find((c) => c.character_name === data.character_name)) {
setError('이미 추가된 캐릭터입니다')
return
}
onAdd(data)
setName('')
setError('')
},
onError: (err) => setError(err.message),
})
const handleSubmit = (e) => {
e.preventDefault()
if (!name.trim()) return
setError('')
searchMutation.mutate(name.trim())
}
// 총합 계산
const charResults = characters.map((char) => {
const charSel = allSelections[char.character_name] || {}
const items = Object.entries(charSel)
.filter(([, sel]) => sel)
.map(([bossId, sel]) => {
const boss = bosses.find((b) => b.id === Number(bossId))
if (!boss) return null
const bd = boss.difficulties.find((d) => d.difficulty === sel.difficulty)
if (!bd) return null
return Math.floor(bd.crystal_price / sel.party)
})
.filter(Boolean)
.sort((a, b) => b - a)
.slice(0, MAX_PER_CHARACTER)
return { count: items.length, revenue: items.reduce((s, v) => s + v, 0) }
})
const totalCount = charResults.reduce((s, r) => s + r.count, 0)
const totalRevenue = charResults.reduce((s, r) => s + r.revenue, 0)
const accountUsage = Math.min(totalCount, MAX_PER_ACCOUNT)
const usagePct = Math.min((accountUsage / MAX_PER_ACCOUNT) * 100, 100)
const totalText = formatMeso(totalRevenue)
const { containerRef: totalContainerRef, textRef: totalTextRef } = useFitText({
maxFontSize: 32,
minFontSize: 14,
value: totalText,
})
return (
<div className="flex flex-col gap-4 min-h-0 flex-1">
{/* 총 수익 카드 (고정) */}
<div
className="rounded-2xl border p-4 space-y-3 shrink-0"
style={{
borderColor: 'var(--selected-border)',
background: 'var(--selected-bg)',
}}
>
<div>
<div className="text-xs" style={{ color: 'var(--accent-bright)' }}> 주간 수익</div>
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
<div
ref={totalTextRef}
className="font-bold leading-tight whitespace-nowrap inline-block"
style={{ color: 'var(--accent-bright)' }}
>
{totalText}
</div>
</div>
</div>
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
<div className="space-y-2">
<div className="text-sm" style={{ color: 'var(--text-muted)' }}> 결정 개수</div>
<div
className="h-2 rounded-full overflow-hidden"
style={{ background: 'var(--progress-track)' }}
>
<div
className="h-full transition-all"
style={{
width: `${usagePct}%`,
background: totalCount > MAX_PER_ACCOUNT ? 'var(--progress-amber)' : 'var(--progress-emerald)',
}}
/>
</div>
</div>
<div className="flex items-baseline gap-1 tabular-nums">
<span
className="text-2xl font-bold leading-none"
style={{ color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-bright)' }}
>
{accountUsage}
</span>
<span
className="text-2xl font-bold leading-none"
style={{
color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-dim)',
opacity: totalCount > MAX_PER_ACCOUNT ? 0.4 : 1,
}}
>
/ {MAX_PER_ACCOUNT}
</span>
</div>
</div>
{totalCount > MAX_PER_ACCOUNT && (
<p className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
한도 {totalCount - MAX_PER_ACCOUNT} 초과
</p>
)}
</div>
{/* 캐릭터 추가 (고정) */}
<div className="shrink-0">
<form onSubmit={handleSubmit} className="flex gap-2">
<div className="relative flex-1 min-w-0">
<span
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: 'var(--input-icon)' }}
>
<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('') }}
onFocus={() => setDropdownOpen(true)}
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
placeholder="캐릭터 닉네임 검색"
className="w-full rounded-lg border-2 pl-10 pr-3 py-2.5 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
<CharacterSuggestDropdown
open={dropdownOpen}
filter={name}
excludeNames={characters.map((c) => c.character_name)}
onSelect={(n) => {
setName(n)
setDropdownOpen(false)
setError('')
searchMutation.mutate(n)
}}
/>
</div>
<button
type="submit"
disabled={searchMutation.isPending}
className="rounded-lg disabled:opacity-50 px-5 py-2.5 text-sm font-medium shrink-0 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{searchMutation.isPending ? '...' : '추가'}
</button>
</form>
{error && (
<p className="text-xs mt-1.5" style={{ color: 'var(--danger-text)' }}>{error}</p>
)}
</div>
{/* 캐릭터 목록 (스크롤) */}
{characters.length > 0 && (
<OverlayScrollbarsComponent
className="flex-1 min-h-0 -mx-4"
options={{
scrollbars: { theme: 'os-theme-maple os-theme-dark', autoHide: 'leave', autoHideDelay: 800 },
}}
defer
>
<div className="px-4">
<Reorder.Group
axis="y"
values={characters}
onReorder={onReorder}
className="space-y-2"
>
{characters.map((char) => (
<CharacterItem
key={char.character_name}
char={char}
isSelected={selectedName === char.character_name}
selections={allSelections[char.character_name] || {}}
bosses={bosses}
onSelect={onSelect}
onRemove={setConfirmRemove}
/>
))}
</Reorder.Group>
</div>
</OverlayScrollbarsComponent>
)}
<ConfirmDialog
open={!!confirmRemove}
onClose={() => setConfirmRemove(null)}
onConfirm={() => {
onRemove(confirmRemove.character_name)
setConfirmRemove(null)
}}
title="캐릭터 삭제"
description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
confirmText="삭제"
destructive
/>
</div>
)
}