2026-04-13 19:17:49 +09:00
|
|
|
|
import { useState } from 'react'
|
|
|
|
|
|
import { useMutation } from '@tanstack/react-query'
|
2026-04-14 08:06:58 +09:00
|
|
|
|
import { Reorder, useDragControls } from 'framer-motion'
|
2026-04-18 12:17:14 +09:00
|
|
|
|
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'
|
2026-04-19 11:26:12 +09:00
|
|
|
|
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'
|
2026-04-13 19:17:49 +09:00
|
|
|
|
import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from '../admin/constants'
|
|
|
|
|
|
|
|
|
|
|
|
const MAX_PER_CHARACTER = 12
|
|
|
|
|
|
const MAX_PER_ACCOUNT = 90
|
|
|
|
|
|
|
|
|
|
|
|
function CharacterContent({ char, selections, bosses }) {
|
2026-04-15 22:07:23 +09:00
|
|
|
|
const bossIndex = new Map(bosses.map((b, i) => [b.id, i]))
|
2026-04-13 19:17:49 +09:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-04-15 22:07:23 +09:00
|
|
|
|
// 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)
|
|
|
|
|
|
)
|
2026-04-13 19:17:49 +09:00
|
|
|
|
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}
|
2026-04-22 00:49:45 +09:00
|
|
|
|
loading="lazy"
|
|
|
|
|
|
decoding="async"
|
2026-04-13 19:17:49 +09:00
|
|
|
|
/>
|
|
|
|
|
|
) : (
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<span className="text-4xl" style={{ color: 'var(--text-dim)' }}>?</span>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</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>
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<span className="text-xs truncate" style={{ color: 'var(--text-dim)' }}>
|
|
|
|
|
|
Lv.{char.character_level} · {char.job_name}
|
|
|
|
|
|
</span>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</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 (
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<Tooltip
|
2026-04-13 19:17:49 +09:00
|
|
|
|
key={item.boss.id}
|
2026-04-14 15:19:04 +09:00
|
|
|
|
text={`${diff?.label || ''} ${item.boss.name} · ${formatMeso(item.revenue)}`}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div className="space-y-0.5">
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="aspect-square rounded overflow-hidden border"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: 'var(--surface-nested)',
|
|
|
|
|
|
borderColor: 'var(--panel-border)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-22 00:49:45 +09:00
|
|
|
|
<img src={item.boss.image_url || '/default.png'} alt="" draggable={false} loading="lazy" decoding="async" className="w-full h-full object-cover select-none" />
|
2026-04-13 20:38:01 +09:00
|
|
|
|
</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>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
</Tooltip>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
)
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="text-xs italic h-[58px] flex items-center"
|
|
|
|
|
|
style={{ color: 'var(--text-dim)' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
보스 미선택
|
|
|
|
|
|
</div>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="flex items-center justify-between border-t pt-2"
|
|
|
|
|
|
style={{ borderColor: 'var(--panel-border)' }}
|
|
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div className="flex items-baseline gap-1 tabular-nums">
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<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>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="text-sm font-semibold tabular-nums whitespace-nowrap"
|
|
|
|
|
|
style={{ color: count > 0 ? 'var(--accent-bright)' : 'var(--text-dim)' }}
|
|
|
|
|
|
>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
{count > 0 ? formatMeso(totalRevenue) : '-'}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function CharacterItem({ char, isSelected, selections, bosses, onSelect, onRemove }) {
|
|
|
|
|
|
const [dragged, setDragged] = useState(false)
|
2026-04-14 08:06:58 +09:00
|
|
|
|
const dragControls = useDragControls()
|
2026-04-13 19:17:49 +09:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Reorder.Item
|
|
|
|
|
|
value={char}
|
2026-04-14 08:06:58 +09:00
|
|
|
|
dragListener={false}
|
|
|
|
|
|
dragControls={dragControls}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
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)
|
|
|
|
|
|
}}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
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)',
|
|
|
|
|
|
}}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
>
|
2026-04-14 08:06:58 +09:00
|
|
|
|
{/* 드래그 핸들 */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
onPointerDown={(e) => { e.preventDefault(); dragControls.start(e) }}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
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)' }}
|
2026-04-14 08:06:58 +09:00
|
|
|
|
>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
<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) }}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
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)' }}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
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)
|
2026-04-19 10:54:12 +09:00
|
|
|
|
const [dropdownOpen, setDropdownOpen] = useState(false)
|
2026-04-13 19:17:49 +09:00
|
|
|
|
|
|
|
|
|
|
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">
|
|
|
|
|
|
{/* 총 수익 카드 (고정) */}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div
|
|
|
|
|
|
className="rounded-2xl border p-4 space-y-3 shrink-0"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
borderColor: 'var(--selected-border)',
|
|
|
|
|
|
background: 'var(--selected-bg)',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
<div>
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div className="text-xs" style={{ color: 'var(--accent-bright)' }}>총 주간 수익</div>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
<div ref={totalContainerRef} className="mt-1 overflow-hidden">
|
|
|
|
|
|
<div
|
|
|
|
|
|
ref={totalTextRef}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
className="font-bold leading-tight whitespace-nowrap inline-block"
|
|
|
|
|
|
style={{ color: 'var(--accent-bright)' }}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
>
|
|
|
|
|
|
{totalText}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div className="grid grid-cols-[1fr_auto] gap-x-3 items-center">
|
|
|
|
|
|
<div className="space-y-2">
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<div className="text-sm" style={{ color: 'var(--text-muted)' }}>총 결정 개수</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="h-2 rounded-full overflow-hidden"
|
|
|
|
|
|
style={{ background: 'var(--progress-track)' }}
|
|
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div
|
2026-04-18 12:15:04 +09:00
|
|
|
|
className="h-full transition-all"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: `${usagePct}%`,
|
|
|
|
|
|
background: totalCount > MAX_PER_ACCOUNT ? 'var(--progress-amber)' : 'var(--progress-emerald)',
|
|
|
|
|
|
}}
|
2026-04-13 20:38:01 +09:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div className="flex items-baseline gap-1 tabular-nums">
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<span
|
|
|
|
|
|
className="text-2xl font-bold leading-none"
|
|
|
|
|
|
style={{ color: totalCount > MAX_PER_ACCOUNT ? 'var(--danger-text)' : 'var(--warning-text-bright)' }}
|
|
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
{accountUsage}
|
|
|
|
|
|
</span>
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<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,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
/ {MAX_PER_ACCOUNT}
|
|
|
|
|
|
</span>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
{totalCount > MAX_PER_ACCOUNT && (
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<p className="text-[10px]" style={{ color: 'var(--warning-text)' }}>
|
|
|
|
|
|
⚠ 한도 {totalCount - MAX_PER_ACCOUNT}개 초과
|
|
|
|
|
|
</p>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
)}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 캐릭터 추가 (고정) */}
|
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
|
<form onSubmit={handleSubmit} className="flex gap-2">
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<div className="relative flex-1 min-w-0">
|
2026-04-18 12:15:04 +09:00
|
|
|
|
<span
|
|
|
|
|
|
className="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
|
|
|
|
|
|
style={{ color: 'var(--input-icon)' }}
|
|
|
|
|
|
>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
<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('') }}
|
2026-04-19 10:54:12 +09:00
|
|
|
|
onFocus={() => setDropdownOpen(true)}
|
|
|
|
|
|
onBlur={() => setTimeout(() => setDropdownOpen(false), 150)}
|
2026-04-13 20:38:01 +09:00
|
|
|
|
placeholder="캐릭터 닉네임 검색"
|
2026-04-18 12:20:55 +09:00
|
|
|
|
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)]"
|
2026-04-18 12:15:04 +09:00
|
|
|
|
style={{
|
|
|
|
|
|
background: 'var(--input-bg)',
|
|
|
|
|
|
borderColor: 'var(--input-border)',
|
|
|
|
|
|
color: 'var(--text-strong)',
|
|
|
|
|
|
}}
|
2026-04-13 20:38:01 +09:00
|
|
|
|
/>
|
2026-04-19 10:54:12 +09:00
|
|
|
|
<CharacterSuggestDropdown
|
|
|
|
|
|
open={dropdownOpen}
|
|
|
|
|
|
filter={name}
|
|
|
|
|
|
excludeNames={characters.map((c) => c.character_name)}
|
|
|
|
|
|
onSelect={(n) => {
|
|
|
|
|
|
setName(n)
|
|
|
|
|
|
setDropdownOpen(false)
|
|
|
|
|
|
setError('')
|
|
|
|
|
|
searchMutation.mutate(n)
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
2026-04-13 20:38:01 +09:00
|
|
|
|
</div>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
<button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
disabled={searchMutation.isPending}
|
2026-04-18 12:15:04 +09:00
|
|
|
|
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)',
|
|
|
|
|
|
}}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
>
|
|
|
|
|
|
{searchMutation.isPending ? '...' : '추가'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
2026-04-18 12:15:04 +09:00
|
|
|
|
{error && (
|
|
|
|
|
|
<p className="text-xs mt-1.5" style={{ color: 'var(--danger-text)' }}>{error}</p>
|
|
|
|
|
|
)}
|
2026-04-13 19:17:49 +09:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 캐릭터 목록 (스크롤) */}
|
|
|
|
|
|
{characters.length > 0 && (
|
2026-04-18 12:17:14 +09:00
|
|
|
|
<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>
|
2026-04-13 19:17:49 +09:00
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
open={!!confirmRemove}
|
|
|
|
|
|
onClose={() => setConfirmRemove(null)}
|
|
|
|
|
|
onConfirm={() => {
|
|
|
|
|
|
onRemove(confirmRemove.character_name)
|
|
|
|
|
|
setConfirmRemove(null)
|
|
|
|
|
|
}}
|
|
|
|
|
|
title="캐릭터 삭제"
|
|
|
|
|
|
description={confirmRemove ? `"${confirmRemove.character_name}" 캐릭터를 목록에서 삭제하시겠습니까?\n\n저장된 보스 선택도 함께 삭제됩니다.` : ''}
|
|
|
|
|
|
confirmText="삭제"
|
|
|
|
|
|
destructive
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|