관리자 페이지 테마 토큰화 + 너비 정리
- 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:
parent
85b9a6b6d2
commit
e78a18dedb
11 changed files with 485 additions and 208 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue