관리자 페이지 테마 토큰화 + 너비 정리

- AdminBoss/AdminFeaturePage/BossList/BossForm/SymbolList/SymbolForm 전체 이관
- Checkbox 공용 컴포넌트 테마 대응
- BossList/SymbolList/AdminImages/AdminFeaturePage 폴백에 max-w-5xl 통일
- BossForm/SymbolForm의 localStorage admin key를 auth store로 교체
- 홈(관리자) 하단 로그아웃 버튼 제거

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-04-19 11:05:25 +09:00
parent 85b9a6b6d2
commit e78a18dedb
11 changed files with 485 additions and 208 deletions

View file

@ -14,11 +14,17 @@ export default function Checkbox({ checked, onChange, disabled, className = '',
disabled={disabled}
tabIndex={tabIndex}
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}`}
className={`${sizeCls} shrink-0 rounded-md border-2 flex items-center justify-center ${
disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'
} ${className}`}
style={checked ? {
borderColor: 'var(--accent)',
background: 'var(--accent)',
color: 'var(--btn-primary-text)',
} : {
borderColor: 'var(--input-border)',
background: 'var(--input-bg)',
}}
>
{checked && (
<svg className={iconSize} viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">

View file

@ -1,8 +1,16 @@
export default function AdminBoss() {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">보스 수익 계산기 관리</h2>
<div className="rounded-lg border border-gray-800 bg-gray-900/50 p-8 text-center text-gray-500">
<h2 className="text-lg font-medium">보스 수익 계산기 관리</h2>
<div
className="rounded-lg border p-8 text-center"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
color: 'var(--text-dim)',
}}
>
준비
</div>
</div>

View file

@ -17,22 +17,29 @@ export default function AdminFeaturePage() {
if (!Component) {
return (
<div className="space-y-4">
<div className="space-y-4 max-w-5xl mx-auto pt-6">
{menu && (
<div>
<h2 className="text-lg font-semibold">{menu.title}</h2>
<p className="text-sm text-gray-500 mt-0.5">{menu.description}</p>
<h2 className="text-lg font-medium">{menu.title}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>{menu.description}</p>
</div>
)}
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-12 text-center">
<div
className="rounded-2xl border border-dashed p-12 text-center"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-4xl mb-3 opacity-30">🛠</div>
<p className="text-gray-400"> 기능에는 관리 페이지가 없습니다</p>
<p className="text-xs text-gray-600 mt-2 font-mono">
<p style={{ color: 'var(--text-muted)' }}> 기능에는 관리 페이지가 없습니다</p>
<p className="text-xs mt-2 font-mono" style={{ color: 'var(--text-dim)' }}>
features/{slug}/{slug.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('')}Admin.jsx
</p>
<Link
to={`/admin/menus/${menu?.id || ''}`}
className="inline-block mt-4 text-xs text-emerald-400 hover:text-emerald-300 transition"
className="inline-block mt-4 text-xs hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--accent)' }}
>
메뉴 정보 편집
</Link>
@ -44,7 +51,10 @@ export default function AdminFeaturePage() {
return (
<Suspense fallback={
<div className="flex items-center justify-center pt-20">
<div className="w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
<div
className="w-6 h-6 border-2 border-t-transparent rounded-full animate-spin"
style={{ borderColor: 'var(--accent)', borderTopColor: 'transparent' }}
/>
</div>
}>
<Component />

View file

@ -1,4 +1,4 @@
import { Link, useNavigate, useOutletContext } from 'react-router-dom'
import { Link, useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { api } from '../../api/client'
@ -72,7 +72,6 @@ function AddCard({ to, icon, label }) {
}
export default function AdminHome() {
const { handleLogout } = useOutletContext() || {}
const { data: menus = [], isLoading: loading } = useQuery({
queryKey: ['admin', 'menus'],
queryFn: () => api('/api/admin/menus').catch(() => []),
@ -157,18 +156,6 @@ export default function AdminHome() {
</div>
</section>
{/* 로그아웃 */}
{handleLogout && (
<div className="pt-4 text-center">
<button
onClick={handleLogout}
className="text-xs transition-colors hover:text-red-500"
style={{ color: 'var(--text-dim)' }}
>
관리자 로그아웃
</button>
</div>
)}
</div>
)
}

View file

@ -482,7 +482,7 @@ export default function AdminImages() {
}
return (
<div className="space-y-6">
<div className="space-y-6 max-w-5xl mx-auto pt-6">
<div className="flex items-end justify-between gap-4 flex-wrap">
<div>
<h2 className="text-lg font-medium">이미지 관리</h2>

View file

@ -9,18 +9,23 @@ 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 className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs text-gray-500">{hint}</span>}
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px] text-red-400">{error}</div>}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{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'
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
export default function AdminMenuForm() {
const navigate = useNavigate()
@ -33,20 +38,18 @@ export default function AdminMenuForm() {
const [form, setForm] = useState({
title: '',
description: '',
slug: '', // ( / )
slug: '',
image_id: null,
image: null, //
image: null,
})
const [errors, setErrors] = useState({})
//
const { data: menuData } = useQuery({
queryKey: ['admin', 'menus', id],
queryFn: () => api(`/api/admin/menus/${id}`),
enabled: isEdit,
})
// id
useEffect(() => {
if (!isEdit) {
setForm({ title: '', description: '', slug: '', image_id: null, image: null })
@ -65,7 +68,6 @@ export default function AdminMenuForm() {
const update = (patch) => setForm((prev) => ({ ...prev, ...patch }))
// slug / ( )
const handleSlugChange = (value) => {
update({ slug: value.replace(/^\/+/, '') })
}
@ -119,24 +121,55 @@ export default function AdminMenuForm() {
})
return (
<div className="space-y-6 max-w-2xl mx-auto">
<div className="space-y-6 max-w-2xl mx-auto pt-6">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5"> 화면에 표시되는 카드의 정보를 설정합니다</p>
<h2 className="text-lg font-medium">{isEdit ? '메뉴 항목 편집' : '메뉴 항목 추가'}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>
화면에 표시되는 카드의 정보를 설정합니다
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
<form
onSubmit={handleSubmit}
className="space-y-5 rounded-2xl border p-6"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
{/* 미리보기 */}
<div className="rounded-xl border border-white/5 bg-gray-950/50 p-4">
<div className="text-xs text-gray-500 mb-3">미리보기</div>
<div className="rounded-xl border border-white/10 bg-gradient-to-br from-gray-900/80 to-gray-900/40 p-5">
<div
className="rounded-xl border p-4"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
}}
>
<div className="text-xs mb-3" style={{ color: 'var(--text-dim)' }}>미리보기</div>
<div
className="rounded-xl border p-5"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
borderColor: 'var(--card-border)',
boxShadow: 'var(--card-shadow)',
}}
>
<div className="flex items-start gap-4">
<div className="shrink-0 w-12 h-12 rounded-xl bg-gradient-to-br from-gray-800 to-gray-900 border border-white/5 flex items-center justify-center overflow-hidden">
<div
className="shrink-0 w-12 h-12 rounded-xl border flex items-center justify-center overflow-hidden"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
borderColor: 'var(--icon-box-border)',
}}
>
<img src={form.image?.url || '/default.png'} alt="" className="w-9 h-9 object-contain" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-semibold">{form.title || '제목 없음'}</h3>
<p className="text-sm text-gray-400 mt-1">{form.description || '설명 없음'}</p>
<h3 className="font-medium">{form.title || '제목 없음'}</h3>
<p className="text-sm mt-1" style={{ color: 'var(--text-muted)' }}>
{form.description || '설명 없음'}
</p>
</div>
</div>
</div>
@ -149,6 +182,7 @@ export default function AdminMenuForm() {
onChange={(e) => update({ title: e.target.value })}
placeholder="예: 주간 보스 수익 계산기"
className={inputCls}
style={inputStyle}
/>
</Field>
@ -159,26 +193,45 @@ export default function AdminMenuForm() {
onChange={(e) => update({ description: e.target.value })}
placeholder="예: 캐릭터별 보스 결정석 수익을 계산합니다"
className={inputCls}
style={inputStyle}
/>
</Field>
<Field label="경로" required error={errors.slug}>
<div className={`flex items-stretch rounded-lg border bg-gray-950 transition focus-within:border-emerald-500/50 ${
errors.slug ? 'border-red-500/40' : 'border-white/10'
}`}>
<span className="flex items-center px-3 text-sm text-gray-500 border-r border-white/10 select-none">/</span>
<div
className="flex items-stretch rounded-lg border focus-within:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]"
style={{
background: 'var(--input-bg)',
borderColor: errors.slug ? 'var(--icon-danger-border)' : 'var(--input-border)',
}}
>
<span
className="flex items-center px-3 text-sm border-r select-none"
style={{ color: 'var(--text-dim)', borderColor: 'var(--input-border)' }}
>
/
</span>
<input
type="text"
value={form.slug}
onChange={(e) => handleSlugChange(e.target.value)}
placeholder="boss-crystal"
className="flex-1 min-w-0 bg-transparent px-3 py-2 text-sm outline-none"
style={{ color: 'var(--text-strong)' }}
/>
</div>
{form.slug.trim() && !errors.slug && (
<div className="text-xs text-gray-500 mt-1.5 flex items-center gap-1.5">
<div className="text-xs mt-1.5 flex items-center gap-1.5" style={{ color: 'var(--text-dim)' }}>
<span>전체 URL:</span>
<code className="text-emerald-400 bg-gray-950/50 px-1.5 py-0.5 rounded">https://maple.caadiq.co.kr{fullUrl}</code>
<code
className="px-1.5 py-0.5 rounded"
style={{
color: 'var(--accent-bright)',
background: 'var(--surface-3)',
}}
>
https://maple.caadiq.co.kr{fullUrl}
</code>
</div>
)}
</Field>
@ -188,12 +241,16 @@ export default function AdminMenuForm() {
<button
type="button"
onClick={() => setPickerOpen(true)}
className="w-16 h-16 rounded-lg border border-white/10 hover:border-emerald-500/40 bg-gray-950 flex items-center justify-center overflow-hidden transition shrink-0 cursor-pointer"
className="w-16 h-16 rounded-lg border flex items-center justify-center overflow-hidden shrink-0 cursor-pointer hover:border-[var(--selected-border)]"
style={{
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
}}
>
{form.image?.url ? (
<img src={form.image.url} alt="" className="max-w-[80%] max-h-[80%] object-contain" />
) : (
<span className="text-2xl text-gray-700">+</span>
<span className="text-2xl" style={{ color: 'var(--text-dim)' }}>+</span>
)}
</button>
<div className="flex-1 min-w-0">
@ -203,13 +260,14 @@ export default function AdminMenuForm() {
<button
type="button"
onClick={() => update({ image_id: null, image: null })}
className="text-xs text-red-400 hover:text-red-300 transition mt-1"
className="text-xs mt-1 hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>
</>
) : (
<div className="text-sm text-gray-500">이미지 선택</div>
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>이미지 선택</div>
)}
</div>
</div>
@ -220,7 +278,11 @@ export default function AdminMenuForm() {
<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"
className="rounded-lg border px-4 py-2.5 text-sm hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
color: 'var(--danger-text)',
}}
>
삭제
</button>
@ -229,14 +291,24 @@ export default function AdminMenuForm() {
<button
type="button"
onClick={() => navigate('/admin')}
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
className="rounded-lg border px-5 py-2.5 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
취소
</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"
className="rounded-lg px-5 py-2.5 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
</button>

View file

@ -4,9 +4,6 @@ import { api } from '../../../api/client'
const PAGE_SIZE = 24
/**
* 업로드된 이미지 하나를 선택하는 모달 피커
*/
export default function ImagePicker({ open, onClose, onSelect, currentImageId }) {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
@ -48,11 +45,31 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm" onClick={onClose}>
<div className="w-full max-w-3xl rounded-2xl bg-gray-900 border border-white/10 shadow-2xl max-h-[90vh] flex flex-col" onClick={(e) => e.stopPropagation()}>
<div className="px-6 py-4 border-b border-white/5 flex items-center justify-between shrink-0">
<h3 className="font-semibold">이미지 선택</h3>
<button onClick={onClose} className="text-gray-500 hover:text-white transition text-xl leading-none">×</button>
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 backdrop-blur-sm"
style={{ background: 'var(--dialog-backdrop)' }}
onClick={onClose}
>
<div
className="w-full max-w-3xl rounded-2xl border shadow-2xl max-h-[90vh] flex flex-col"
style={{
backgroundImage: 'linear-gradient(to bottom, var(--dialog-bg-from), var(--dialog-bg-to))',
borderColor: 'var(--dialog-border)',
}}
onClick={(e) => e.stopPropagation()}
>
<div
className="px-6 py-4 border-b flex items-center justify-between shrink-0"
style={{ borderColor: 'var(--panel-border)' }}
>
<h3 className="font-semibold" style={{ color: 'var(--text-strong)' }}>이미지 선택</h3>
<button
onClick={onClose}
className="text-xl leading-none hover:bg-[var(--row-hover-bg)] w-7 h-7 rounded flex items-center justify-center"
style={{ color: 'var(--text-dim)' }}
>
×
</button>
</div>
{/* 검색 */}
@ -63,9 +80,14 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="이미지 이름으로 검색..."
className="w-full rounded-lg border border-white/10 bg-gray-950 pl-10 pr-4 py-2.5 text-sm outline-none focus:border-emerald-500/50 transition"
className="w-full rounded-lg border pl-10 pr-4 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)',
}}
/>
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">🔍</span>
<span className="absolute left-3 top-1/2 -translate-y-1/2" style={{ color: 'var(--input-icon)' }}>🔍</span>
</div>
</div>
@ -74,55 +96,84 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
{isLoading ? (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="aspect-square rounded-lg bg-white/[0.02] animate-pulse" />
<div
key={i}
className="aspect-square rounded-lg animate-pulse"
style={{ background: 'var(--skeleton-bg)' }}
/>
))}
</div>
) : images.length === 0 ? (
<div className="py-12 text-center text-gray-500 text-sm">
<div className="py-12 text-center text-sm" style={{ color: 'var(--text-dim)' }}>
{debouncedSearch ? '검색 결과가 없습니다' : '업로드된 이미지가 없습니다'}
</div>
) : (
<div className="grid gap-3 grid-cols-3 sm:grid-cols-4 lg:grid-cols-6">
{images.map((image) => (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className={`group rounded-lg border overflow-hidden transition ${
currentImageId === image.id
? 'border-emerald-500/60 ring-2 ring-emerald-500/30'
: 'border-white/5 hover:border-white/20'
}`}
title={image.name}
>
<div className="aspect-square bg-gradient-to-br from-gray-900 to-gray-950 flex items-center justify-center p-3">
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
</div>
<div className="px-2 py-1.5 border-t border-white/5 bg-gray-950/50">
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
))}
{images.map((image) => {
const isSelected = currentImageId === image.id
return (
<button
key={image.id}
type="button"
onClick={() => { onSelect(image); onClose() }}
className="group rounded-lg border overflow-hidden"
style={{
borderColor: isSelected ? 'var(--selected-border)' : 'var(--panel-border)',
boxShadow: isSelected ? '0 0 0 2px var(--ring-info)' : undefined,
}}
title={image.name}
>
<div
className="aspect-square flex items-center justify-center p-3"
style={{ backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))' }}
>
<img src={image.url} alt={image.name} className="max-w-full max-h-full object-contain" />
</div>
<div
className="px-2 py-1.5 border-t"
style={{
borderColor: 'var(--panel-border)',
background: 'var(--surface-3)',
}}
>
<div className="text-xs truncate">{image.name}</div>
</div>
</button>
)
})}
</div>
)}
</div>
{/* 페이지네이션 + 액션 */}
<div className="px-6 py-4 border-t border-white/5 flex items-center justify-between shrink-0 gap-3">
<div
className="px-6 py-4 border-t flex items-center justify-between shrink-0 gap-3"
style={{ borderColor: 'var(--panel-border)' }}
>
{totalPages > 1 ? (
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="w-8 h-8 rounded border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
<span className="text-xs text-gray-400 px-2">{page} / {totalPages}</span>
<span className="text-xs px-2" style={{ color: 'var(--text-muted)' }}>{page} / {totalPages}</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="w-8 h-8 rounded border border-white/10 hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
className="w-8 h-8 rounded border hover:bg-[var(--btn-bg-hover)] disabled:opacity-30 disabled:cursor-not-allowed flex items-center justify-center text-sm"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
</button>
@ -133,7 +184,8 @@ export default function ImagePicker({ open, onClose, onSelect, currentImageId })
<button
type="button"
onClick={() => { onSelect(null); onClose() }}
className="text-sm text-red-400 hover:text-red-300 transition"
className="text-sm hover:text-[var(--danger-text-strong)]"
style={{ color: 'var(--danger-text)' }}
>
이미지 제거
</button>

View file

@ -5,6 +5,7 @@ import { api } from '../../../api/client'
import ConfirmDialog from '../../../components/ConfirmDialog'
import Checkbox from '../../../components/Checkbox'
import Select from '../../../components/Select'
import { useAuthStore } from '../../../stores/auth'
import { DIFFICULTIES, formatMeso, getDifficultyImageUrl } from './constants'
const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}` }))
@ -13,18 +14,23 @@ 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 className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs text-gray-500">{hint}</span>}
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px] text-red-400">{error}</div>}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{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'
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
function emptyDifficultyState() {
const obj = {}
@ -134,7 +140,7 @@ export default function BossForm() {
}))
formData.append('difficulties', JSON.stringify(diffsPayload))
const adminKey = localStorage.getItem('maple-admin-key')
const adminKey = useAuthStore.getState().apiKey
const url = isEdit
? `/api/admin/boss-crystal/bosses/${id}`
: '/api/admin/boss-crystal/bosses'
@ -174,13 +180,21 @@ export default function BossForm() {
const displayImage = imagePreview || existingImageUrl
return (
<div className="space-y-6 max-w-2xl mx-auto">
<div className="space-y-6 max-w-2xl mx-auto pt-6">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '보스 편집' : '보스 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5">보스 이름과 난이도별 결정 정보를 입력합니다</p>
<h2 className="text-lg font-medium">{isEdit ? '보스 편집' : '보스 추가'}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>보스 이름과 난이도별 결정 정보를 입력합니다</p>
</div>
<form onSubmit={handleSubmit} className="space-y-5 rounded-2xl border border-white/5 bg-gray-900/40 p-6">
<form
onSubmit={handleSubmit}
className="space-y-5 rounded-2xl border p-6"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}}
>
{/* 이름 + 최대 인원 */}
<div className="grid grid-cols-[1fr_auto] gap-3">
<Field label="보스 이름" required error={errors.name}>
@ -190,6 +204,7 @@ export default function BossForm() {
onChange={(e) => setName(e.target.value)}
placeholder="예: 검은 마법사"
className={inputCls}
style={inputStyle}
/>
</Field>
<Field label="최대 인원">
@ -205,26 +220,32 @@ export default function BossForm() {
{/* 이미지 */}
<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'
}`}
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
background: 'var(--surface-3)',
borderColor: errors.image ? 'var(--icon-danger-border)' : 'var(--dashed-border)',
}}
>
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
<div
className="w-32 h-32 rounded-lg border flex items-center justify-center overflow-hidden shrink-0"
style={{
background: 'var(--surface-nested)',
borderColor: 'var(--panel-border)',
}}
>
{displayImage ? (
<img src={displayImage} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-5xl text-gray-700">+</span>
<span className="text-5xl" style={{ color: 'var(--text-dim)' }}>+</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-300">
<div className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
</div>
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF WebP로 자동 변환됩니다</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-dim)' }}>PNG, JPG, GIF WebP로 자동 변환됩니다</p>
{imageFile && (
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
<div className="text-xs mt-2 truncate" style={{ color: 'var(--accent-bright)' }}>📎 {imageFile.name}</div>
)}
</div>
<input
@ -246,12 +267,14 @@ export default function BossForm() {
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'
}`}
className="rounded-lg border p-3"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--panel-border)',
opacity: v.enabled ? 1 : 0.6,
}}
>
<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 })}
@ -269,7 +292,6 @@ export default function BossForm() {
/>
</div>
{/* 가격 */}
<div className="flex-1 min-w-0">
<div className="relative">
<input
@ -282,12 +304,18 @@ export default function BossForm() {
}}
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'
}`}
className="w-full rounded-lg border pl-4 pr-28 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] disabled:opacity-50 disabled:cursor-not-allowed"
style={{
background: 'var(--input-bg)',
borderColor: priceErr ? 'var(--icon-danger-border)' : 'var(--input-border)',
color: 'var(--text-strong)',
}}
/>
{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">
<span
className="absolute right-3 top-1/2 -translate-y-1/2 text-sm pointer-events-none whitespace-nowrap"
style={{ color: 'var(--accent-bright)' }}
>
{formatMeso(Number(v.crystal_price))}
</span>
)}
@ -300,13 +328,16 @@ export default function BossForm() {
</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"
className="rounded-lg border px-4 py-2.5 text-sm hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
color: 'var(--danger-text)',
}}
>
삭제
</button>
@ -315,14 +346,24 @@ export default function BossForm() {
<button
type="button"
onClick={() => navigate('..')}
className="rounded-lg border border-white/10 px-5 py-2.5 text-sm hover:bg-white/5 transition"
className="rounded-lg border px-5 py-2.5 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
취소
</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"
className="rounded-lg px-5 py-2.5 text-sm font-medium disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{saveMutation.isPending ? '저장 중...' : (isEdit ? '저장' : '추가')}
</button>

View file

@ -16,13 +16,16 @@ import { DIFFICULTIES, formatMeso, getDifficultyBadgeStyle } from './constants'
function BossCardContent({ boss, dragging = false }) {
return (
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
dragging
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
: 'border-white/5'
}`}>
<div
className="flex items-stretch rounded-2xl border"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
borderColor: dragging ? 'var(--selected-border)' : 'var(--card-border)',
boxShadow: dragging ? '0 12px 32px rgba(16, 185, 129, 0.25)' : 'var(--card-shadow)',
}}
>
{/* 핸들 자리 */}
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
<div className="flex items-center px-2 cursor-grab active:cursor-grabbing" style={{ color: 'var(--text-dim)' }}>
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
<circle cx="4" cy="4" r="1.5" />
<circle cx="10" cy="4" r="1.5" />
@ -34,13 +37,19 @@ function BossCardContent({ boss, dragging = false }) {
</div>
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
<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">
<div
className="shrink-0 w-14 h-14 rounded-xl border flex items-center justify-center overflow-hidden"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
borderColor: 'var(--icon-box-border)',
}}
>
<img src={boss.image_url || '/default.png'} alt={boss.name} className="w-full h-full object-cover" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<h3 className="font-semibold truncate">{boss.name}</h3>
<span className="text-xs text-gray-500 shrink-0">최대 {boss.max_party_size}</span>
<h3 className="font-medium truncate">{boss.name}</h3>
<span className="text-xs shrink-0" style={{ color: 'var(--text-dim)' }}>최대 {boss.max_party_size}</span>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{DIFFICULTIES.filter((d) => boss.difficulties?.some((bd) => bd.difficulty === d.key)).map((d) => {
@ -80,17 +89,15 @@ function SortableBossCard({ boss }) {
style={style}
className={`relative ${isDragging ? 'opacity-30' : ''}`}
>
{/* 드래그 핸들 (좌측) */}
<button
type="button"
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-[var(--row-hover-bg)] transition touch-none"
aria-label="순서 변경"
/>
{/* 카드 본체 - Link */}
<Link to={`bosses/${boss.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
<Link to={`bosses/${boss.id}`} className="block group hover:[&_h3]:text-[var(--accent-hover-text)] [&_h3]:transition">
<BossCardContent boss={boss} />
</Link>
</div>
@ -143,15 +150,20 @@ export default function BossList() {
const activeBoss = items.find((b) => b.id === activeId)
return (
<div className="space-y-6">
<div className="space-y-6 max-w-5xl mx-auto pt-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>
<h2 className="text-lg font-medium">보스 결정 관리</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>보스 정보 난이도별 결정 가격을 관리합니다</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"
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
<span className="text-base leading-none">+</span>
보스 추가
@ -161,14 +173,24 @@ export default function BossList() {
{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 key={i} className="h-32 rounded-2xl animate-pulse" style={{ background: 'var(--skeleton-bg)' }} />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div
className="rounded-2xl border border-dashed p-16 text-center"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<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">
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>등록된 보스가 없습니다</p>
<Link
to="bosses/new"
className="text-sm hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--accent)' }}
>
보스 추가하기
</Link>
</div>

View file

@ -4,6 +4,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { api } from '../../../api/client'
import Select from '../../../components/Select'
import ConfirmDialog from '../../../components/ConfirmDialog'
import { useAuthStore } from '../../../stores/auth'
const TYPE_OPTIONS = [
{ value: '아케인', label: '아케인' },
@ -11,7 +12,12 @@ const TYPE_OPTIONS = [
{ value: '그랜드 어센틱', label: '그랜드 어센틱' },
]
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'
const inputCls = 'w-full rounded-lg border px-3 py-2 text-sm outline-none focus:border-[var(--input-border-focus)] hover:border-[var(--input-border-hover)]'
const inputStyle = {
background: 'var(--input-bg)',
borderColor: 'var(--input-border)',
color: 'var(--text-strong)',
}
function formatMesoKorean(n) {
if (!n || n <= 0) return ''
@ -38,9 +44,15 @@ function MesoInput({ value, onChange, ...rest }) {
onChange(digits)
}}
className={`${inputCls} tabular-nums text-right`}
style={inputStyle}
{...rest}
/>
<div className="text-sm text-amber-300 mt-1 text-right tabular-nums min-h-[18px]">{korean || '\u00A0'}</div>
<div
className="text-sm mt-1 text-right tabular-nums min-h-[18px]"
style={{ color: 'var(--warning-text-bright)' }}
>
{korean || '\u00A0'}
</div>
</div>
)
}
@ -49,13 +61,13 @@ 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 className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{label} {required && <span style={{ color: 'var(--danger-text)' }}>*</span>}
</label>
{hint && <span className="text-xs text-gray-500">{hint}</span>}
{hint && <span className="text-xs" style={{ color: 'var(--text-dim)' }}>{hint}</span>}
</div>
{children}
{error && <div className="text-[11px] text-red-400">{error}</div>}
{error && <div className="text-[11px]" style={{ color: 'var(--danger-text)' }}>{error}</div>}
</div>
)
}
@ -79,7 +91,6 @@ export default function SymbolForm() {
const [confirmDelete, setConfirmDelete] = useState(false)
const [error, setError] = useState('')
//
const { data: symbolData } = useQuery({
queryKey: ['admin', 'symbol', 'symbols', id],
queryFn: () => api(`/api/admin/symbol/symbols/${id}`),
@ -148,7 +159,7 @@ export default function SymbolForm() {
))
if (imageFile) formData.append('image', imageFile)
const adminKey = localStorage.getItem('maple-admin-key')
const adminKey = useAuthStore.getState().apiKey
const url = isEdit ? `/api/admin/symbol/symbols/${id}` : '/api/admin/symbol/symbols'
const res = await fetch(url, {
method: isEdit ? 'PATCH' : 'POST',
@ -188,33 +199,51 @@ export default function SymbolForm() {
const displayImage = imagePreview || existingImageUrl
const panelStyle = {
background: 'var(--panel-bg)',
borderColor: 'var(--panel-border)',
boxShadow: 'var(--panel-shadow)',
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="max-w-2xl mx-auto space-y-6 pt-6">
<div>
<h2 className="text-lg font-semibold">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
<p className="text-sm text-gray-500 mt-0.5">심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
<h2 className="text-lg font-medium">{isEdit ? '심볼 편집' : '심볼 추가'}</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>심볼 정보와 레벨별 필요 개수/메소를 입력합니다</p>
</div>
{/* 기본 정보 */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-5">
<div className="text-sm font-semibold text-emerald-300">기본 정보</div>
<div className="rounded-2xl border p-6 space-y-5" style={panelStyle}>
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>기본 정보</div>
<Field label="심볼 이미지" required={!isEdit}>
<label className="flex items-center gap-4 rounded-xl border-2 border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 bg-gray-950/50 p-4 transition cursor-pointer">
<div className="w-32 h-32 rounded-lg bg-gray-900 border border-white/5 flex items-center justify-center overflow-hidden shrink-0">
<label
className="flex items-center gap-4 rounded-xl border-2 border-dashed p-4 cursor-pointer hover:border-[var(--selected-border)]"
style={{
background: 'var(--surface-3)',
borderColor: 'var(--dashed-border)',
}}
>
<div
className="w-32 h-32 rounded-lg border flex items-center justify-center overflow-hidden shrink-0"
style={{
background: 'var(--surface-nested)',
borderColor: 'var(--panel-border)',
}}
>
{displayImage ? (
<img src={displayImage} alt="" className="w-full h-full object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<span className="text-5xl text-gray-700">+</span>
<span className="text-5xl" style={{ color: 'var(--text-dim)' }}>+</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-300">
<div className="text-sm font-medium" style={{ color: 'var(--text-emphasis)' }}>
{displayImage ? '클릭하여 이미지 변경' : '클릭하여 이미지 업로드'}
</div>
<p className="text-xs text-gray-500 mt-1">PNG, JPG, GIF WebP로 자동 변환됩니다</p>
<p className="text-xs mt-1" style={{ color: 'var(--text-dim)' }}>PNG, JPG, GIF WebP로 자동 변환됩니다</p>
{imageFile && (
<div className="text-xs text-emerald-400 mt-2 truncate">📎 {imageFile.name}</div>
<div className="text-xs mt-2 truncate" style={{ color: 'var(--accent-bright)' }}>📎 {imageFile.name}</div>
)}
</div>
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleFile} className="hidden" />
@ -232,6 +261,7 @@ export default function SymbolForm() {
value={region}
onChange={(e) => setRegion(e.target.value)}
className={inputCls}
style={inputStyle}
placeholder="소멸의 여로"
/>
</Field>
@ -244,6 +274,7 @@ export default function SymbolForm() {
value={maxLevel}
onChange={(e) => { setMaxLevel(e.target.value); adjustLevelRows(e.target.value) }}
className={inputCls}
style={inputStyle}
min="2"
/>
</Field>
@ -253,6 +284,7 @@ export default function SymbolForm() {
value={dailyDefault}
onChange={(e) => setDailyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
/>
</Field>
<Field label="기본 주간퀘 획득량">
@ -261,35 +293,35 @@ export default function SymbolForm() {
value={weeklyDefault}
onChange={(e) => setWeeklyDefault(e.target.value)}
className={inputCls}
style={inputStyle}
/>
</Field>
</div>
</div>
</div>
</div>
{/* 레벨별 설정 */}
<div className="rounded-2xl border border-white/5 bg-gray-900/40 p-6 space-y-4">
<div className="rounded-2xl border p-6 space-y-4" style={panelStyle}>
<div className="flex items-baseline justify-between">
<div className="text-sm font-semibold text-emerald-300">레벨별 필요 개수 · 메소</div>
<div className="text-xs text-gray-500">레벨 N N+1 업그레이드 기준 (만렙-1)</div>
<div className="text-sm font-semibold" style={{ color: 'var(--accent-bright)' }}>레벨별 필요 개수 · 메소</div>
<div className="text-xs" style={{ color: 'var(--text-dim)' }}>레벨 N N+1 업그레이드 기준 (만렙-1)</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-xs text-gray-500 uppercase border-b border-white/5">
<tr className="text-xs uppercase border-b" style={{ color: 'var(--text-dim)', borderColor: 'var(--panel-border)' }}>
<th className="py-2 px-3 text-left font-medium w-20">레벨</th>
<th className="py-2 px-3 text-left font-medium">필요 심볼 </th>
<th className="py-2 px-3 text-left font-medium">메소</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
<tbody>
{levels.map((l, idx) => (
<tr key={l.level}>
<td className="py-1.5 px-3 text-gray-400 tabular-nums">
Lv.<span className="text-gray-200 font-semibold">{l.level}</span>
<span className="text-gray-600 mx-1"></span>
<tr key={l.level} className="border-t first:border-t-0" style={{ borderColor: 'var(--row-divider)' }}>
<td className="py-1.5 px-3 tabular-nums" style={{ color: 'var(--text-muted)' }}>
Lv.<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{l.level}</span>
<span className="mx-1" style={{ color: 'var(--text-dim)' }}></span>
{l.level + 1}
</td>
<td className="py-1.5 px-3">
@ -298,6 +330,7 @@ export default function SymbolForm() {
value={l.required_count}
onChange={(e) => updateLevel(idx, 'required_count', e.target.value)}
className={`${inputCls} max-w-36`}
style={inputStyle}
placeholder="0"
/>
</td>
@ -324,7 +357,12 @@ export default function SymbolForm() {
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="rounded-lg border border-red-500/40 bg-red-500/10 hover:bg-red-500/20 text-red-300 px-4 py-2 text-sm font-medium transition"
className="rounded-lg border px-4 py-2 text-sm font-medium hover:bg-[var(--danger-bg-hover)]"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
삭제
</button>
@ -334,7 +372,12 @@ export default function SymbolForm() {
<button
type="button"
onClick={() => navigate('..')}
className="rounded-lg border border-white/10 hover:bg-white/5 text-gray-300 px-4 py-2 text-sm transition"
className="rounded-lg border px-4 py-2 text-sm hover:bg-[var(--btn-bg-hover)]"
style={{
background: 'var(--btn-bg)',
borderColor: 'var(--btn-border)',
color: 'var(--text-emphasis)',
}}
>
취소
</button>
@ -342,7 +385,12 @@ export default function SymbolForm() {
type="button"
onClick={handleSubmit}
disabled={saveMutation.isPending}
className="rounded-lg bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white px-5 py-2 text-sm font-semibold shadow-lg shadow-emerald-500/20 transition"
className="rounded-lg px-5 py-2 text-sm font-semibold disabled:opacity-50 hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
{saveMutation.isPending ? '저장 중...' : isEdit ? '저장' : '추가'}
</button>
@ -350,7 +398,14 @@ export default function SymbolForm() {
</div>
{error && (
<div className="rounded-lg border border-red-500/40 bg-red-500/10 text-red-300 text-sm px-4 py-2">
<div
className="rounded-lg border text-sm px-4 py-2"
style={{
borderColor: 'var(--icon-danger-border)',
background: 'var(--icon-danger-bg)',
color: 'var(--danger-text)',
}}
>
{error}
</div>
)}

View file

@ -21,12 +21,15 @@ const TYPE_COLOR = {
function SymbolCardContent({ symbol, dragging = false }) {
const color = TYPE_COLOR[symbol.type] || TYPE_COLOR['아케인']
return (
<div className={`flex items-stretch rounded-2xl border bg-gradient-to-br from-gray-900/80 to-gray-900/40 ${
dragging
? 'border-emerald-500/60 shadow-2xl shadow-emerald-500/30'
: 'border-white/5'
}`}>
<div className="flex items-center px-2 text-gray-700 cursor-grab active:cursor-grabbing">
<div
className="flex items-stretch rounded-2xl border"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--card-bg-from), var(--card-bg-to))',
borderColor: dragging ? 'var(--selected-border)' : 'var(--card-border)',
boxShadow: dragging ? '0 12px 32px rgba(16, 185, 129, 0.25)' : 'var(--card-shadow)',
}}
>
<div className="flex items-center px-2 cursor-grab active:cursor-grabbing" style={{ color: 'var(--text-dim)' }}>
<svg width="14" height="20" viewBox="0 0 14 20" fill="currentColor">
<circle cx="4" cy="4" r="1.5" /><circle cx="10" cy="4" r="1.5" />
<circle cx="4" cy="10" r="1.5" /><circle cx="10" cy="10" r="1.5" />
@ -34,21 +37,27 @@ function SymbolCardContent({ symbol, dragging = false }) {
</svg>
</div>
<div className="flex-1 min-w-0 flex items-start gap-3 p-4 pl-2">
<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">
<div
className="shrink-0 w-14 h-14 rounded-xl border flex items-center justify-center overflow-hidden"
style={{
backgroundImage: 'linear-gradient(to bottom right, var(--icon-box-from), var(--icon-box-to))',
borderColor: 'var(--icon-box-border)',
}}
>
{symbol.image_url ? (
<img src={symbol.image_url} alt="" className="w-12 h-12 object-contain" style={{ imageRendering: 'pixelated' }} />
) : (
<span className="text-gray-700 text-2xl">?</span>
<span className="text-2xl" style={{ color: 'var(--text-dim)' }}>?</span>
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="font-semibold truncate">{symbol.region}</h3>
<h3 className="font-medium truncate">{symbol.region}</h3>
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${color.text} ${color.bg} ${color.border}`}>
{symbol.type}
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 tabular-nums">
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-xs tabular-nums" style={{ color: 'var(--text-dim)' }}>
<span>만렙 {symbol.max_level}</span>
<span>일퀘 {symbol.daily_default}</span>
<span>주간퀘 {symbol.weekly_default}</span>
@ -72,10 +81,10 @@ function SortableSymbolCard({ symbol }) {
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-white/5 transition touch-none"
className="absolute left-0 top-0 bottom-0 w-8 z-10 cursor-grab active:cursor-grabbing rounded-l-2xl hover:bg-[var(--row-hover-bg)] transition touch-none"
aria-label="순서 변경"
/>
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-emerald-300 [&_h3]:transition">
<Link to={`symbols/${symbol.id}`} className="block group hover:[&_h3]:text-[var(--accent-hover-text)] [&_h3]:transition">
<SymbolCardContent symbol={symbol} />
</Link>
</div>
@ -126,15 +135,20 @@ export default function SymbolList() {
const activeSymbol = items.find((s) => s.id === activeId)
return (
<div className="space-y-6">
<div className="space-y-6 max-w-5xl mx-auto pt-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>
<h2 className="text-lg font-medium">심볼 관리</h2>
<p className="text-sm mt-0.5" style={{ color: 'var(--text-dim)' }}>심볼 정보 레벨별 필요 개수/메소를 관리합니다</p>
</div>
<Link
to="symbols/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"
className="flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium hover:bg-[var(--btn-primary-bg-hover)]"
style={{
background: 'var(--btn-primary-bg)',
color: 'var(--btn-primary-text)',
boxShadow: 'var(--btn-primary-shadow)',
}}
>
<span className="text-base leading-none">+</span>
심볼 추가
@ -144,14 +158,24 @@ export default function SymbolList() {
{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-24 rounded-2xl bg-white/[0.02] animate-pulse" />
<div key={i} className="h-24 rounded-2xl animate-pulse" style={{ background: 'var(--skeleton-bg)' }} />
))}
</div>
) : items.length === 0 ? (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-16 text-center">
<div
className="rounded-2xl border border-dashed p-16 text-center"
style={{
borderColor: 'var(--dashed-border)',
background: 'var(--skeleton-bg)',
}}
>
<div className="text-5xl mb-3 opacity-30">🔮</div>
<p className="text-gray-400 mb-4">등록된 심볼이 없습니다</p>
<Link to="symbols/new" className="text-sm text-emerald-400 hover:text-emerald-300 transition">
<p className="mb-4" style={{ color: 'var(--text-muted)' }}>등록된 심볼이 없습니다</p>
<Link
to="symbols/new"
className="text-sm hover:text-[var(--accent-hover-text)]"
style={{ color: 'var(--accent)' }}
>
심볼 추가하기
</Link>
</div>