보스 결정 관리 페이지(프론트) 추가

- 보스 추가/편집/삭제 폼 (이름, 이미지, 난이도별 가격/인원)
- 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:
caadiq 2026-04-13 15:41:47 +09:00
parent 1de5dcb7b9
commit 39cda0d958
9 changed files with 559 additions and 10 deletions

View file

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

View 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>
)
}

View 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>
)
}

View file

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

View file

@ -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>
) )
} }

View 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>
)
}

View 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>
)
}

View 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`
}

View file

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