보스 결정 관리 페이지(프론트) 추가
- 보스 추가/편집/삭제 폼 (이름, 이미지, 난이도별 가격/인원) - BossList: 등록된 보스 카드 목록 + 추가 버튼 - 동적 라우트가 sub-path 지원하도록 변경 (:slug/*) - 커스텀 Checkbox/Select 컴포넌트 - number input 화살표 전역 제거 - 가격 입력 시 메소 단위 미리보기 표시 - 결정석 → 결정으로 용어 통일 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1de5dcb7b9
commit
39cda0d958
9 changed files with 559 additions and 10 deletions
|
|
@ -20,11 +20,11 @@ export default function App() {
|
||||||
<Route path="images" element={<AdminImages />} />
|
<Route path="images" element={<AdminImages />} />
|
||||||
<Route path="menus/new" element={<AdminMenuForm />} />
|
<Route path="menus/new" element={<AdminMenuForm />} />
|
||||||
<Route path="menus/:id" element={<AdminMenuForm />} />
|
<Route path="menus/:id" element={<AdminMenuForm />} />
|
||||||
<Route path=":slug" element={<AdminFeaturePage />} />
|
<Route path=":slug/*" element={<AdminFeaturePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* 동적 기능 페이지 */}
|
{/* 동적 기능 페이지 */}
|
||||||
<Route path="/:slug" element={<FeaturePage />} />
|
<Route path="/:slug/*" element={<FeaturePage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
29
frontend/src/components/Checkbox.jsx
Normal file
29
frontend/src/components/Checkbox.jsx
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
/**
|
||||||
|
* 커스텀 체크박스
|
||||||
|
* <Checkbox checked={x} onChange={(checked) => ...} />
|
||||||
|
*/
|
||||||
|
export default function Checkbox({ checked, onChange, disabled, className = '', size = 'md' }) {
|
||||||
|
const sizeCls = size === 'sm' ? 'w-4 h-4' : 'w-5 h-5'
|
||||||
|
const iconSize = size === 'sm' ? 'text-[10px]' : 'text-xs'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="checkbox"
|
||||||
|
aria-checked={checked}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={(e) => { e.stopPropagation(); !disabled && onChange?.(!checked) }}
|
||||||
|
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center transition ${
|
||||||
|
checked
|
||||||
|
? 'border-emerald-500 bg-emerald-500 text-white'
|
||||||
|
: 'border-white/20 bg-gray-950 hover:border-white/40'
|
||||||
|
} ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'} ${className}`}
|
||||||
|
>
|
||||||
|
{checked && (
|
||||||
|
<svg className={iconSize} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
frontend/src/components/Select.jsx
Normal file
69
frontend/src/components/Select.jsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 커스텀 드롭다운 셀렉트
|
||||||
|
* <Select value={x} onChange={...} options={[{value, label}]} />
|
||||||
|
*/
|
||||||
|
export default function Select({ value, onChange, options, disabled, className = '', placeholder = '선택', align = 'left' }) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const ref = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handler = (e) => {
|
||||||
|
if (!ref.current?.contains(e.target)) setOpen(false)
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handler)
|
||||||
|
return () => document.removeEventListener('mousedown', handler)
|
||||||
|
}, [open])
|
||||||
|
|
||||||
|
const selected = options.find((o) => o.value === value)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className={`relative ${className}`}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => !disabled && setOpen((v) => !v)}
|
||||||
|
className={`w-full flex items-center justify-between gap-2 rounded-lg border bg-gray-950 px-3 py-2 text-sm transition outline-none ${
|
||||||
|
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||||
|
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
|
>
|
||||||
|
<span className={selected ? '' : 'text-gray-500'}>
|
||||||
|
{selected ? selected.label : placeholder}
|
||||||
|
</span>
|
||||||
|
<svg className={`w-3.5 h-3.5 text-gray-500 transition ${open ? 'rotate-180' : ''}`} viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className={`absolute top-full mt-1 z-20 min-w-full rounded-lg border border-white/10 bg-gray-900 shadow-xl overflow-hidden ${
|
||||||
|
align === 'right' ? 'right-0' : 'left-0'
|
||||||
|
}`}>
|
||||||
|
<div className="max-h-60 overflow-y-auto py-1">
|
||||||
|
{options.map((opt) => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => { onChange(opt.value); setOpen(false) }}
|
||||||
|
className={`w-full text-left px-3 py-1.5 text-sm transition flex items-center gap-2 ${
|
||||||
|
opt.value === value
|
||||||
|
? 'bg-emerald-500/10 text-emerald-300'
|
||||||
|
: 'hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.value === value && (
|
||||||
|
<svg className="w-3 h-3 shrink-0" viewBox="0 0 12 12" fill="none">
|
||||||
|
<path d="M2.5 6L5 8.5L9.5 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<span className={opt.value !== value ? 'pl-5' : ''}>{opt.label}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
export default function BossCrystal() {
|
export default function BossCrystal() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-2xl font-bold">주간 보스 결정석 계산기</h1>
|
<h1 className="text-2xl font-bold">주간 보스 결정 계산기</h1>
|
||||||
<p className="text-gray-400">준비 중입니다.</p>
|
<p className="text-gray-400">준비 중입니다.</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
|
import { Routes, Route } from 'react-router-dom'
|
||||||
|
import BossList from './admin/BossList'
|
||||||
|
import BossForm from './admin/BossForm'
|
||||||
|
|
||||||
export default function BossCrystalAdmin() {
|
export default function BossCrystalAdmin() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<Routes>
|
||||||
<h2 className="text-lg font-semibold">보스 결정석 관리</h2>
|
<Route index element={<BossList />} />
|
||||||
<p className="text-sm text-gray-500">보스 정보 및 결정석 가격을 관리합니다</p>
|
<Route path="bosses/new" element={<BossForm />} />
|
||||||
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-12 text-center text-gray-500">
|
<Route path="bosses/:id" element={<BossForm />} />
|
||||||
준비 중
|
</Routes>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
337
frontend/src/features/boss-crystal/admin/BossForm.jsx
Normal file
337
frontend/src/features/boss-crystal/admin/BossForm.jsx
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
import { useState, useEffect, useRef } from 'react'
|
||||||
|
import { useNavigate, useParams } from 'react-router-dom'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import ConfirmDialog from '../../../components/ConfirmDialog'
|
||||||
|
import Checkbox from '../../../components/Checkbox'
|
||||||
|
import Select from '../../../components/Select'
|
||||||
|
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
||||||
|
|
||||||
|
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인` }))
|
||||||
|
|
||||||
|
function Field({ label, hint, error, required, children }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<div className="flex items-baseline justify-between">
|
||||||
|
<label className="text-sm font-medium text-gray-300">
|
||||||
|
{label} {required && <span className="text-red-400">*</span>}
|
||||||
|
</label>
|
||||||
|
{hint && <span className="text-xs text-gray-500">{hint}</span>}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
{error && <div className="text-[11px] text-red-400">{error}</div>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls = 'w-full rounded-lg border border-white/10 bg-gray-950 px-3 py-2 text-sm outline-none focus:border-emerald-500/50 transition'
|
||||||
|
|
||||||
|
function emptyDifficultyState() {
|
||||||
|
const obj = {}
|
||||||
|
DIFFICULTIES.forEach((d) => {
|
||||||
|
obj[d.key] = { enabled: false, crystal_price: '', max_party_size: 6 }
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BossForm() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const { id } = useParams()
|
||||||
|
const isEdit = !!id
|
||||||
|
const fileInputRef = useRef(null)
|
||||||
|
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [imageFile, setImageFile] = useState(null)
|
||||||
|
const [imagePreview, setImagePreview] = useState(null)
|
||||||
|
const [existingImageUrl, setExistingImageUrl] = useState(null)
|
||||||
|
const [difficulties, setDifficulties] = useState(emptyDifficultyState())
|
||||||
|
const [errors, setErrors] = useState({})
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false)
|
||||||
|
|
||||||
|
// 편집 모드 데이터 로드
|
||||||
|
const { data: bossData } = useQuery({
|
||||||
|
queryKey: ['admin', 'boss-crystal', 'bosses', id],
|
||||||
|
queryFn: () => api(`/api/admin/boss-crystal/bosses/${id}`),
|
||||||
|
enabled: isEdit,
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isEdit) {
|
||||||
|
setName('')
|
||||||
|
setImageFile(null)
|
||||||
|
setImagePreview(null)
|
||||||
|
setExistingImageUrl(null)
|
||||||
|
setDifficulties(emptyDifficultyState())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (bossData) {
|
||||||
|
setName(bossData.name || '')
|
||||||
|
setExistingImageUrl(bossData.image_url || null)
|
||||||
|
setImagePreview(null)
|
||||||
|
setImageFile(null)
|
||||||
|
|
||||||
|
const next = emptyDifficultyState()
|
||||||
|
bossData.difficulties?.forEach((d) => {
|
||||||
|
next[d.difficulty] = {
|
||||||
|
enabled: true,
|
||||||
|
crystal_price: String(d.crystal_price),
|
||||||
|
max_party_size: d.max_party_size,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
setDifficulties(next)
|
||||||
|
}
|
||||||
|
}, [isEdit, id, bossData])
|
||||||
|
|
||||||
|
const handleImagePick = (file) => {
|
||||||
|
if (!file || !file.type.startsWith('image/')) return
|
||||||
|
setImageFile(file)
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => setImagePreview(e.target.result)
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateDifficulty = (key, patch) => {
|
||||||
|
setDifficulties((prev) => ({ ...prev, [key]: { ...prev[key], ...patch } }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const errs = {}
|
||||||
|
if (!name.trim()) errs.name = '보스 이름을 입력해주세요'
|
||||||
|
if (!isEdit && !imageFile) errs.image = '보스 이미지를 업로드해주세요'
|
||||||
|
|
||||||
|
const enabledKeys = DIFFICULTIES.filter((d) => difficulties[d.key].enabled)
|
||||||
|
if (enabledKeys.length === 0) {
|
||||||
|
errs.difficulties = '하나 이상의 난이도를 선택해주세요'
|
||||||
|
} else {
|
||||||
|
for (const d of enabledKeys) {
|
||||||
|
const v = difficulties[d.key]
|
||||||
|
const price = Number(v.crystal_price)
|
||||||
|
if (!v.crystal_price || isNaN(price) || price <= 0) {
|
||||||
|
errs[`price_${d.key}`] = '가격을 입력해주세요'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setErrors(errs)
|
||||||
|
return Object.keys(errs).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('name', name.trim())
|
||||||
|
if (imageFile) formData.append('image', imageFile)
|
||||||
|
|
||||||
|
const diffsPayload = DIFFICULTIES
|
||||||
|
.filter((d) => difficulties[d.key].enabled)
|
||||||
|
.map((d) => ({
|
||||||
|
difficulty: d.key,
|
||||||
|
crystal_price: Number(difficulties[d.key].crystal_price),
|
||||||
|
max_party_size: Number(difficulties[d.key].max_party_size),
|
||||||
|
}))
|
||||||
|
formData.append('difficulties', JSON.stringify(diffsPayload))
|
||||||
|
|
||||||
|
const adminKey = localStorage.getItem('maple-admin-key')
|
||||||
|
const url = isEdit
|
||||||
|
? `/api/admin/boss-crystal/bosses/${id}`
|
||||||
|
: '/api/admin/boss-crystal/bosses'
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: isEdit ? 'PATCH' : 'POST',
|
||||||
|
headers: { 'x-admin-key': adminKey },
|
||||||
|
body: formData,
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (!res.ok) throw new Error(json.error || '저장 실패')
|
||||||
|
return json
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'boss-crystal', 'bosses'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['boss-crystal'] })
|
||||||
|
navigate('..')
|
||||||
|
},
|
||||||
|
onError: (err) => alert(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api(`/api/admin/boss-crystal/bosses/${id}`, { method: 'DELETE' }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin', 'boss-crystal', 'bosses'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['boss-crystal'] })
|
||||||
|
navigate('..')
|
||||||
|
},
|
||||||
|
onError: (err) => alert(err.message),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleSubmit = (e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!validate()) return
|
||||||
|
saveMutation.mutate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayImage = imagePreview || existingImageUrl
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 max-w-2xl">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">{isEdit ? '보스 편집' : '보스 추가'}</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">보스 이름과 난이도별 결정 정보를 입력합니다</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
|
||||||
|
{/* 이름 */}
|
||||||
|
<Field label="보스 이름" required error={errors.name}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
placeholder="예: 검은 마법사"
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* 이미지 */}
|
||||||
|
<Field label="보스 이미지" required={!isEdit} error={errors.image}>
|
||||||
|
<label
|
||||||
|
className={`flex items-center gap-4 rounded-xl border-2 border-dashed bg-gray-950/50 p-4 transition cursor-pointer ${
|
||||||
|
errors.image
|
||||||
|
? 'border-red-500/40'
|
||||||
|
: 'border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{displayImage ? (
|
||||||
|
<img src={displayImage} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<span className="text-5xl text-gray-700">+</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-sm font-medium text-gray-300">
|
||||||
|
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF 등 → WebP로 자동 변환됩니다</p>
|
||||||
|
{imageFile && (
|
||||||
|
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => handleImagePick(e.target.files[0])}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* 난이도 */}
|
||||||
|
<Field label="난이도별 결정 정보" required error={errors.difficulties} hint="활성화한 난이도만 저장됩니다">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{DIFFICULTIES.map((d) => {
|
||||||
|
const v = difficulties[d.key]
|
||||||
|
const priceErr = errors[`price_${d.key}`]
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={d.key}
|
||||||
|
className={`rounded-lg border bg-gray-950/50 p-3 transition ${
|
||||||
|
v.enabled ? 'border-white/10' : 'border-white/5 opacity-60'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 체크박스 + 난이도 이미지 (이미지 클릭으로도 토글 가능) */}
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2.5 shrink-0 cursor-pointer select-none"
|
||||||
|
onClick={() => updateDifficulty(d.key, { enabled: !v.enabled })}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={v.enabled}
|
||||||
|
onChange={(checked) => updateDifficulty(d.key, { enabled: checked })}
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
src={getDifficultyImageUrl(d.key)}
|
||||||
|
alt={d.label}
|
||||||
|
className="h-5"
|
||||||
|
onError={(e) => { e.currentTarget.style.display = 'none' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 가격 */}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={v.crystal_price}
|
||||||
|
onChange={(e) => updateDifficulty(d.key, { crystal_price: e.target.value })}
|
||||||
|
disabled={!v.enabled}
|
||||||
|
placeholder="결정 가격"
|
||||||
|
className={`w-full rounded-lg border bg-gray-900 pl-4 pr-28 py-2 text-sm outline-none focus:border-emerald-500/50 disabled:opacity-50 disabled:cursor-not-allowed transition ${
|
||||||
|
priceErr ? 'border-red-500/40' : 'border-white/10'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{v.crystal_price && v.enabled && (
|
||||||
|
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-sm text-emerald-400/80 pointer-events-none whitespace-nowrap">
|
||||||
|
{formatMeso(Number(v.crystal_price))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 최대 인원 */}
|
||||||
|
<Select
|
||||||
|
value={v.max_party_size}
|
||||||
|
onChange={(val) => updateDifficulty(d.key, { max_party_size: val })}
|
||||||
|
options={PARTY_OPTIONS}
|
||||||
|
disabled={!v.enabled}
|
||||||
|
className="w-20 shrink-0"
|
||||||
|
align="right"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{/* 버튼 */}
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
{isEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setConfirmDelete(true)}
|
||||||
|
className="rounded-lg border border-red-500/30 text-red-400 hover:bg-red-500/10 hover:border-red-500/50 px-4 py-2.5 text-sm transition"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate('..')}
|
||||||
|
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saveMutation.isPending}
|
||||||
|
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 px-5 py-2.5 text-sm font-medium disabled:opacity-50 transition shadow-lg shadow-emerald-500/20"
|
||||||
|
>
|
||||||
|
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
onClose={() => setConfirmDelete(false)}
|
||||||
|
onConfirm={() => deleteMutation.mutate()}
|
||||||
|
title="보스 삭제"
|
||||||
|
description={`"${name}" 보스를 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`}
|
||||||
|
confirmText="삭제"
|
||||||
|
destructive
|
||||||
|
loading={deleteMutation.isPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/features/boss-crystal/admin/BossList.jsx
Normal file
78
frontend/src/features/boss-crystal/admin/BossList.jsx
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { api } from '../../../api/client'
|
||||||
|
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
|
||||||
|
|
||||||
|
export default function BossList() {
|
||||||
|
const { data: bosses = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['admin', 'boss-crystal', 'bosses'],
|
||||||
|
queryFn: () => api('/api/admin/boss-crystal/bosses').catch(() => []),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-end justify-between gap-4 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold">보스 결정 관리</h2>
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">보스 정보 및 난이도별 결정 가격을 관리합니다</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
to="bosses/new"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-emerald-600 hover:bg-emerald-500 px-4 py-2 text-sm font-medium transition shadow-lg shadow-emerald-500/20"
|
||||||
|
>
|
||||||
|
<span className="text-base leading-none">+</span>
|
||||||
|
보스 추가
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div key={i} className="h-32 rounded-2xl bg-white/[0.02] animate-pulse" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : bosses.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
|
||||||
|
<div className="text-5xl mb-3 opacity-30">⚔️</div>
|
||||||
|
<p className="text-gray-400 mb-4">등록된 보스가 없습니다</p>
|
||||||
|
<Link to="bosses/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
|
||||||
|
첫 보스 추가하기 →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{bosses.map((boss) => (
|
||||||
|
<Link
|
||||||
|
key={boss.id}
|
||||||
|
to={`bosses/${boss.id}`}
|
||||||
|
className="group rounded-2xl border border-white/5 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-4 hover:border-emerald-500/30 transition"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="shrink-0 w-14 h-14 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
|
||||||
|
<img
|
||||||
|
src={boss.image_url || '/default.png'}
|
||||||
|
alt={boss.name}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold group-hover:text-emerald-300 transition truncate">{boss.name}</h3>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{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] px-1.5 py-0.5 rounded border ${d.color}`} title={`${formatMeso(bd.crystal_price)} / ${bd.max_party_size}인`}>
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
frontend/src/features/boss-crystal/admin/constants.js
Normal file
24
frontend/src/features/boss-crystal/admin/constants.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// 난이도 정의 (key, label, color)
|
||||||
|
export const DIFFICULTIES = [
|
||||||
|
{ key: 'easy', label: '이지', color: 'text-green-400 border-green-500/30 bg-green-500/10' },
|
||||||
|
{ key: 'normal', label: '노말', color: 'text-gray-300 border-gray-500/30 bg-gray-500/10' },
|
||||||
|
{ key: 'hard', label: '하드', color: 'text-rose-400 border-rose-500/30 bg-rose-500/10' },
|
||||||
|
{ key: 'chaos', label: '카오스', color: 'text-amber-400 border-amber-500/30 bg-amber-500/10' },
|
||||||
|
{ key: 'extreme', label: '익스트림', color: 'text-red-500 border-red-500/30 bg-red-500/10' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function formatMeso(n) {
|
||||||
|
if (!n || n < 10000) return (n || 0).toLocaleString()
|
||||||
|
if (n >= 100_000_000) {
|
||||||
|
const uk = Math.floor(n / 100_000_000)
|
||||||
|
const man = Math.floor((n % 100_000_000) / 10_000)
|
||||||
|
return man > 0 ? `${uk}억 ${man.toLocaleString()}만` : `${uk}억`
|
||||||
|
}
|
||||||
|
return `${Math.floor(n / 10_000).toLocaleString()}만`
|
||||||
|
}
|
||||||
|
|
||||||
|
// difficulty 이미지 URL (S3)
|
||||||
|
export const DIFFICULTY_IMAGE_BASE = 'https://s3.caadiq.co.kr/maplestory/crystal/difficulty'
|
||||||
|
export function getDifficultyImageUrl(key) {
|
||||||
|
return `${DIFFICULTY_IMAGE_BASE}/${key}.webp`
|
||||||
|
}
|
||||||
|
|
@ -24,3 +24,13 @@ a {
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* number input 화살표 숨기기 */
|
||||||
|
input[type="number"]::-webkit-inner-spin-button,
|
||||||
|
input[type="number"]::-webkit-outer-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
input[type="number"] {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue