해방 날짜 계산기 + DatePicker 테마 토큰화
- Liberation 루트/ProgressBar/QuestSelector/WeeklyDefault/WeeklyScheduler 전체 이관 - DatePicker 드롭다운(연도/월/일 선택) 모두 semantic 토큰으로 대응 - 세그먼트 바, 배지, 탭, 초기화 버튼, 보스 Row 모두 테마 대응 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f0a04c51ff
commit
fe65c107c8
6 changed files with 367 additions and 180 deletions
|
|
@ -97,14 +97,19 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setIsOpen(!isOpen))}
|
||||
className={`w-full h-12 rounded-lg border bg-gray-950 px-4 text-base flex items-center justify-between transition ${
|
||||
isOpen ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
className="w-full h-12 rounded-lg border px-4 text-base flex items-center justify-between"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: isOpen ? 'var(--input-border-focus)' : 'var(--input-border)',
|
||||
}}
|
||||
>
|
||||
<span className={value ? 'text-white' : 'text-gray-500'}>
|
||||
<span style={{ color: value ? 'var(--text-strong)' : 'var(--input-placeholder)' }}>
|
||||
{value ? formatDisplay(value) : placeholder}
|
||||
</span>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" className="text-gray-400">
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 24 24" fill="none"
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
|
|
@ -117,22 +122,29 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -6 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute z-50 mt-2 left-0 rounded-xl border border-white/10 bg-gray-900 shadow-2xl p-5"
|
||||
style={{ width: 420 }}
|
||||
className="absolute z-50 mt-2 left-0 rounded-xl border p-5"
|
||||
style={{
|
||||
width: 420,
|
||||
background: 'var(--popup-bg)',
|
||||
borderColor: 'var(--popup-border)',
|
||||
boxShadow: 'var(--popup-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? prevYearRange : prevMonth)}
|
||||
disabled={viewMode === 'years' ? !canGoPrevYearRange : (year === minYear && month === 0)}
|
||||
className="p-1.5 rounded hover:bg-white/5 disabled:opacity-30 disabled:cursor-not-allowed text-gray-400"
|
||||
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)] disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ChevronIcon dir="left" size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => setViewMode(viewMode === 'days' ? 'years' : 'days'))}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-200 hover:text-emerald-300 transition"
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-[var(--accent-bright)]"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{viewMode === 'years' ? `${years[0]} - ${years[years.length - 1]}` : `${year}년 ${month + 1}월`}
|
||||
<ChevronIcon dir={viewMode !== 'days' ? 'up' : 'down'} size={14} className="transition-transform" />
|
||||
|
|
@ -140,7 +152,8 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<button
|
||||
type="button"
|
||||
onClick={(e) => stop(e, viewMode === 'years' ? nextYearRange : nextMonth)}
|
||||
className="p-1.5 rounded hover:bg-white/5 text-gray-400"
|
||||
className="p-1.5 rounded hover:bg-[var(--row-hover-bg)]"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
<ChevronIcon dir="right" size={18} />
|
||||
</button>
|
||||
|
|
@ -149,43 +162,51 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
<AnimatePresence mode="wait">
|
||||
{viewMode === 'years' ? (
|
||||
<motion.div key="years" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.12 }}>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">연도</div>
|
||||
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}>연도</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px', marginBottom: '12px' }}>
|
||||
{years.map((y) => (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectYear(y))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
year === y
|
||||
? 'bg-emerald-500 text-white'
|
||||
: currentYear === y
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
))}
|
||||
{years.map((y) => {
|
||||
const isActive = year === y
|
||||
const isCurrent = currentYear === y && !isActive
|
||||
return (
|
||||
<button
|
||||
key={y}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectYear(y))}
|
||||
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
|
||||
style={isActive ? {
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
} : {
|
||||
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{y}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-center text-xs text-gray-500 mb-2">월</div>
|
||||
<div className="text-center text-xs mb-2" style={{ color: 'var(--text-dim)' }}>월</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: '6px' }}>
|
||||
{monthNames.map((m, i) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectMonth(i))}
|
||||
className={`py-2 rounded-lg text-sm transition ${
|
||||
month === i
|
||||
? 'bg-emerald-500 text-white'
|
||||
: (currentYear === year && currentMonth === i)
|
||||
? 'text-emerald-300 hover:bg-white/5'
|
||||
: 'text-gray-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
{monthNames.map((m, i) => {
|
||||
const isActive = month === i
|
||||
const isCurrent = (currentYear === year && currentMonth === i) && !isActive
|
||||
return (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
onClick={(e) => stop(e, () => selectMonth(i))}
|
||||
className="py-2 rounded-lg text-sm hover:bg-[var(--row-hover-bg)]"
|
||||
style={isActive ? {
|
||||
background: 'var(--btn-primary-bg)',
|
||||
color: 'var(--btn-primary-text)',
|
||||
} : {
|
||||
color: isCurrent ? 'var(--accent-bright)' : 'var(--text-emphasis)',
|
||||
}}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
|
|
@ -194,9 +215,11 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
{['일', '월', '화', '수', '목', '금', '토'].map((d, i) => (
|
||||
<div
|
||||
key={d}
|
||||
className={`text-center text-xs font-medium py-1 ${
|
||||
i === 0 ? 'text-red-400/80' : i === 6 ? 'text-sky-400/80' : 'text-gray-500'
|
||||
}`}
|
||||
className="text-center text-xs font-medium py-1"
|
||||
style={{
|
||||
color: i === 0 ? 'var(--danger-text)' : i === 6 ? '#60a5fa' : 'var(--text-dim)',
|
||||
opacity: i === 0 || i === 6 ? 0.8 : 1,
|
||||
}}
|
||||
>
|
||||
{d}
|
||||
</div>
|
||||
|
|
@ -207,20 +230,25 @@ export default function DatePicker({ value, onChange, placeholder = '날짜 선
|
|||
const dw = i % 7
|
||||
const selected = isSelected(day)
|
||||
const today = isToday(day)
|
||||
const textColor = today && !selected ? 'var(--accent-bright)'
|
||||
: day && !selected && !today && dw === 0 ? 'var(--danger-text)'
|
||||
: day && !selected && !today && dw === 6 ? '#60a5fa'
|
||||
: day && !selected && !today ? 'var(--text-emphasis)'
|
||||
: undefined
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
disabled={!day}
|
||||
onClick={(e) => day && stop(e, () => selectDate(day))}
|
||||
style={{ aspectRatio: '1 / 1' }}
|
||||
className={`rounded-full text-base font-medium flex items-center justify-center transition-all
|
||||
${!day ? '' : 'hover:bg-white/5'}
|
||||
${selected ? 'bg-emerald-500 text-white hover:bg-emerald-500' : ''}
|
||||
${today && !selected ? 'text-emerald-300 font-bold' : ''}
|
||||
${day && !selected && !today && dw === 0 ? 'text-red-400' : ''}
|
||||
${day && !selected && !today && dw === 6 ? 'text-sky-400' : ''}
|
||||
${day && !selected && !today && dw > 0 && dw < 6 ? 'text-gray-300' : ''}
|
||||
style={{
|
||||
aspectRatio: '1 / 1',
|
||||
background: selected ? 'var(--btn-primary-bg)' : undefined,
|
||||
color: selected ? 'var(--btn-primary-text)' : textColor,
|
||||
fontWeight: today && !selected ? 'bold' : undefined,
|
||||
}}
|
||||
className={`rounded-full text-base font-medium flex items-center justify-center
|
||||
${!day ? '' : 'hover:bg-[var(--row-hover-bg)]'}
|
||||
`}
|
||||
>
|
||||
{day}
|
||||
|
|
|
|||
|
|
@ -277,48 +277,76 @@ export default function Liberation() {
|
|||
{[
|
||||
{ key: 'genesis', label: '제네시스 해방', img: genesisImg.data?.url },
|
||||
{ key: 'destiny', label: '데스티니 해방', img: destinyImg.data?.url },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setLiberationType(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3 transition ${
|
||||
liberationType === tab.key
|
||||
? 'border-emerald-500/50 bg-emerald-500/10 text-emerald-200 shadow-lg shadow-emerald-500/10'
|
||||
: 'border-white/10 bg-gray-900/40 text-gray-400 hover:border-white/20 hover:text-gray-200'
|
||||
}`}
|
||||
>
|
||||
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
|
||||
<span className="text-base font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
))}
|
||||
].map((tab) => {
|
||||
const active = liberationType === tab.key
|
||||
return (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
onClick={() => setLiberationType(tab.key)}
|
||||
className="flex-1 flex items-center justify-center gap-3 rounded-2xl border px-5 py-3"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
boxShadow: 'var(--btn-primary-shadow)',
|
||||
} : {
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{tab.img && <img src={tab.img} alt="" className="w-8 h-8 object-contain" />}
|
||||
<span className="text-base font-semibold">{tab.label}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{liberationType === 'destiny' ? (
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-16 text-center space-y-3 flex flex-col items-center justify-center" style={{ minHeight: 'calc(100vh - 220px)' }}>
|
||||
<div className="text-2xl font-bold text-gray-300">구현 예정</div>
|
||||
<div className="text-sm text-gray-500">데스티니 해방 계산기는 준비 중입니다.</div>
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-16 text-center space-y-3 flex flex-col items-center justify-center"
|
||||
style={{
|
||||
minHeight: 'calc(100vh - 220px)',
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="text-2xl font-bold" style={{ color: 'var(--text-emphasis)' }}>구현 예정</div>
|
||||
<div className="text-sm" style={{ color: 'var(--text-dim)' }}>데스티니 해방 계산기는 준비 중입니다.</div>
|
||||
</div>
|
||||
) : (<>
|
||||
{/* 계산 모드 탭 */}
|
||||
<div className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border border-white/10 bg-gray-950/60">
|
||||
<div
|
||||
className="max-w-3xl mx-auto flex gap-1 p-1 rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{[
|
||||
{ key: 'simple', label: '단순 계산' },
|
||||
{ key: 'weekly', label: '주차별 계산' },
|
||||
].map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCalcMode(t.key)}
|
||||
className={`flex-1 h-10 rounded-lg text-sm font-semibold transition ${
|
||||
calcMode === t.key
|
||||
? 'bg-emerald-500/20 text-emerald-300'
|
||||
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
].map((t) => {
|
||||
const active = calcMode === t.key
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCalcMode(t.key)}
|
||||
className="flex-1 h-10 rounded-lg text-sm font-semibold"
|
||||
style={active ? {
|
||||
background: 'var(--selected-bg)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
color: 'var(--text-muted)',
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<ProgressBar
|
||||
|
|
@ -328,12 +356,19 @@ export default function Liberation() {
|
|||
/>
|
||||
|
||||
{/* 현재 진행 상태 입력 */}
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div className="text-lg font-semibold text-emerald-300">현재 진행 상태</div>
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>현재 진행 상태</div>
|
||||
|
||||
<div className="grid gap-3 grid-cols-3">
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">시작 날짜</label>
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>시작 날짜</label>
|
||||
<DatePicker
|
||||
value={formatDate(state.startDate)}
|
||||
onChange={(d) => setState((prev) => ({ ...prev, startDate: dayjs(d).toISOString() }))}
|
||||
|
|
@ -341,7 +376,7 @@ export default function Liberation() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">진행 중인 퀘스트</label>
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>진행 중인 퀘스트</label>
|
||||
<QuestSelector
|
||||
value={state.startChapter}
|
||||
onChange={(idx) => setState((prev) => ({ ...prev, startChapter: idx }))}
|
||||
|
|
@ -349,15 +384,28 @@ export default function Liberation() {
|
|||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs text-gray-400">현재 흔적</label>
|
||||
<div className="flex items-stretch rounded-lg border border-white/10 bg-gray-950 transition focus-within:border-emerald-500/50 hover:border-white/20">
|
||||
<label className="block text-xs" style={{ color: 'var(--text-muted)' }}>현재 흔적</label>
|
||||
<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: 'var(--input-border)',
|
||||
}}
|
||||
>
|
||||
<PointsInput
|
||||
value={state.currentPoints}
|
||||
max={3000}
|
||||
onChange={(n) => setState((prev) => ({ ...prev, currentPoints: n }))}
|
||||
className="flex-1 min-w-0 bg-transparent px-3 h-12 text-base text-right tabular-nums outline-none"
|
||||
style={{ color: 'var(--text-strong)' }}
|
||||
/>
|
||||
<span className="flex items-center px-3 text-base text-gray-500 border-l border-white/10 select-none tabular-nums">
|
||||
<span
|
||||
className="flex items-center px-3 text-base border-l select-none tabular-nums"
|
||||
style={{
|
||||
borderColor: 'var(--input-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
/ {(GENESIS_CHAPTERS[state.startChapter]?.required ?? 0).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -381,7 +429,12 @@ export default function Liberation() {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setResetOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-red-500/50 bg-red-500/10 hover:bg-red-500/20 text-red-300 hover:text-red-200 px-5 py-2.5 text-sm font-semibold transition shadow-lg shadow-red-500/10"
|
||||
className="inline-flex items-center gap-2 rounded-lg border px-5 py-2.5 text-sm font-semibold hover:bg-[var(--danger-bg-hover)]"
|
||||
style={{
|
||||
borderColor: 'var(--icon-danger-border)',
|
||||
background: 'var(--icon-danger-bg)',
|
||||
color: 'var(--danger-text)',
|
||||
}}
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||
<path d="M2 3H14M6 3V2C6 1.45 6.45 1 7 1H9C9.55 1 10 1.45 10 2V3M3 3L4 14C4 14.55 4.45 15 5 15H11C11.55 15 12 14.55 12 14L13 3" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
|
|
|||
|
|
@ -19,9 +19,13 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
|
||||
const renderSegment = ({ chapter, status, current }) => {
|
||||
const pct = (current / chapter.required) * 100
|
||||
const bg = status === 'done' ? '#10b981' : status === 'active' ? '#fbbf24' : 'transparent'
|
||||
const bg = status === 'done' ? 'var(--progress-emerald)' : status === 'active' ? 'var(--progress-amber)' : 'transparent'
|
||||
return (
|
||||
<div key={`seg-${chapter.idx}`} className="flex-1 h-2 rounded bg-gray-900 overflow-hidden">
|
||||
<div
|
||||
key={`seg-${chapter.idx}`}
|
||||
className="flex-1 h-2 rounded overflow-hidden"
|
||||
style={{ background: 'var(--progress-track)' }}
|
||||
>
|
||||
<div
|
||||
className="h-full transition-all"
|
||||
style={{ width: `${pct}%`, background: bg }}
|
||||
|
|
@ -32,7 +36,7 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
|
||||
const renderPortrait = ({ chapter, status }) => (
|
||||
<div key={`p-${chapter.idx}`} className="flex-1 flex flex-col items-center gap-1.5 min-w-0">
|
||||
<div className={`w-full aspect-square rounded-lg overflow-hidden transition ${
|
||||
<div className={`w-full aspect-square rounded-lg overflow-hidden ${
|
||||
status === 'active' ? 'shadow-lg shadow-amber-500/20' :
|
||||
status === 'pending' ? 'opacity-50' : ''
|
||||
}`}>
|
||||
|
|
@ -42,19 +46,29 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
className={`block w-full h-full object-cover ${status === 'pending' ? 'grayscale' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${
|
||||
status === 'done' ? 'text-emerald-300' :
|
||||
status === 'active' ? 'text-amber-300' : 'text-gray-500'
|
||||
}`}>
|
||||
<div
|
||||
className="text-sm font-medium"
|
||||
style={{
|
||||
color: status === 'done' ? 'var(--accent-bright)' :
|
||||
status === 'active' ? 'var(--warning-text-bright)' : 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
{chapter.boss}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-5">
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-5"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
{/* 섹션 제목 */}
|
||||
<div className="text-lg font-semibold text-emerald-300">퀘스트 진행 상황</div>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>퀘스트 진행 상황</div>
|
||||
|
||||
{/* 1차 / 2차 라벨 + 세그먼트 바 */}
|
||||
<div className="space-y-3">
|
||||
|
|
@ -79,11 +93,17 @@ export default function ProgressBar({ startChapter, currentPoints, completionDat
|
|||
</div>
|
||||
|
||||
{/* 예상 해방 날짜 */}
|
||||
<div className="flex items-center justify-center gap-3 pt-4 border-t border-white/5">
|
||||
<span className="text-lg font-semibold text-white">예상 해방 날짜</span>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-xl font-bold tabular-nums text-amber-400">
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="text-gray-500 font-normal">미정</span>}
|
||||
<div
|
||||
className="flex items-center justify-center gap-3 pt-4 border-t"
|
||||
style={{ borderColor: 'var(--panel-border)' }}
|
||||
>
|
||||
<span className="text-lg font-semibold" style={{ color: 'var(--text-strong)' }}>예상 해방 날짜</span>
|
||||
<span style={{ color: 'var(--text-dim)' }}>·</span>
|
||||
<span
|
||||
className="text-xl font-bold tabular-nums"
|
||||
style={{ color: 'var(--warning-text-bright)' }}
|
||||
>
|
||||
{completionDate ? formatKoreanDate(completionDate) : <span className="font-normal" style={{ color: 'var(--text-dim)' }}>미정</span>}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,23 +26,30 @@ export default function QuestSelector({ value, onChange }) {
|
|||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className={`w-full h-12 flex items-center gap-3 rounded-lg border bg-gray-950 pl-2 pr-3 transition ${
|
||||
open ? 'border-emerald-500/50' : 'border-white/10 hover:border-white/20'
|
||||
}`}
|
||||
className="w-full h-12 flex items-center gap-3 rounded-lg border pl-2 pr-3"
|
||||
style={{
|
||||
background: 'var(--input-bg)',
|
||||
borderColor: open ? 'var(--input-border-focus)' : 'var(--input-border)',
|
||||
color: 'var(--text-strong)',
|
||||
}}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-900">
|
||||
<div
|
||||
className="w-9 h-9 rounded overflow-hidden shrink-0"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${selected.boss}.webp`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className="flex-1 text-left text-sm font-medium text-gray-100">
|
||||
<span className="flex-1 text-left text-sm font-medium">
|
||||
{selected.boss}
|
||||
</span>
|
||||
<svg
|
||||
width="14" height="14" viewBox="0 0 12 12" fill="none"
|
||||
className={`text-gray-400 transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
className={`transition-transform ${open ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--input-icon)' }}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
|
@ -55,7 +62,12 @@ export default function QuestSelector({ value, onChange }) {
|
|||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -6, scale: 0.98 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border border-white/10 bg-gray-900 shadow-2xl py-1 max-h-72 overflow-y-auto origin-top"
|
||||
className="absolute top-full left-0 right-0 mt-1 z-50 rounded-lg border py-1 max-h-72 overflow-y-auto origin-top"
|
||||
style={{
|
||||
background: 'var(--popup-bg)',
|
||||
borderColor: 'var(--popup-border)',
|
||||
boxShadow: 'var(--popup-shadow)',
|
||||
}}
|
||||
>
|
||||
{GENESIS_CHAPTERS.map((chapter) => {
|
||||
const isSelected = chapter.idx === value
|
||||
|
|
@ -64,20 +76,25 @@ export default function QuestSelector({ value, onChange }) {
|
|||
key={chapter.idx}
|
||||
type="button"
|
||||
onClick={() => { onChange(chapter.idx); setOpen(false) }}
|
||||
className={`w-full flex items-center gap-3 px-2 py-1.5 transition ${
|
||||
isSelected ? 'bg-emerald-500/10' : 'hover:bg-white/5'
|
||||
}`}
|
||||
className="w-full flex items-center gap-3 px-2 py-1.5"
|
||||
style={isSelected ? { background: 'var(--option-selected-bg)' } : undefined}
|
||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.background = 'var(--row-hover-bg)' }}
|
||||
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.background = '' }}
|
||||
>
|
||||
<div className="w-9 h-9 rounded overflow-hidden shrink-0 bg-gray-950">
|
||||
<div
|
||||
className="w-9 h-9 rounded overflow-hidden shrink-0"
|
||||
style={{ background: 'var(--surface-nested)' }}
|
||||
>
|
||||
<img
|
||||
src={`${QUEST_BOSS_IMAGE_BASE}/${chapter.boss}.webp`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<span className={`flex-1 text-left text-sm font-medium ${
|
||||
isSelected ? 'text-emerald-300' : 'text-gray-200'
|
||||
}`}>
|
||||
<span
|
||||
className="flex-1 text-left text-sm font-medium"
|
||||
style={{ color: isSelected ? 'var(--option-selected-text)' : 'var(--text-emphasis)' }}
|
||||
>
|
||||
{chapter.boss}
|
||||
</span>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -7,11 +7,11 @@ const PARTY_OPTIONS = [1, 2, 3, 4, 5, 6].map((n) => ({ value: n, label: `${n}인
|
|||
const NONE_DIFFICULTY = { key: 'none', label: '격파 불가', points: 0 }
|
||||
|
||||
function diffLabel(d, party) {
|
||||
if (d.key === 'none') return <span className="text-gray-500">격파 불가</span>
|
||||
if (d.key === 'none') return <span style={{ color: 'var(--text-dim)' }}>격파 불가</span>
|
||||
const earned = calcPoints(d.points, party)
|
||||
return (
|
||||
<span>
|
||||
{d.label} <span className="text-emerald-400">+{earned}</span>
|
||||
{d.label} <span style={{ color: 'var(--accent-bright)' }}>+{earned}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -22,13 +22,20 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
|||
.map((d) => ({ value: d.key, label: diffLabel(d, sel.party) }))
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 h-16 transition">
|
||||
<div className="flex items-center gap-3 rounded-lg px-3 h-16">
|
||||
<Tooltip text={boss.name}>
|
||||
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt="" className="w-10 h-10 rounded-md object-cover shrink-0" />
|
||||
</Tooltip>
|
||||
<span className="text-base font-semibold flex-1 truncate">
|
||||
{boss.name}
|
||||
{monthly && <span className="ml-1.5 text-[11px] text-amber-400/80 font-medium">월간</span>}
|
||||
{monthly && (
|
||||
<span
|
||||
className="ml-1.5 text-[11px] font-medium"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
월간
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<div className="w-36">
|
||||
|
|
@ -54,13 +61,18 @@ export function BossRow({ boss, sel, onChange, monthly = false, showDone = true
|
|||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onChange({ done: !sel.done })}
|
||||
className={`shrink-0 w-20 rounded-md h-8 text-xs font-semibold transition border ${
|
||||
disabled
|
||||
? 'border-white/5 text-gray-700 cursor-not-allowed'
|
||||
: sel.done
|
||||
? 'bg-emerald-500/20 border-emerald-500/50 text-emerald-300'
|
||||
: 'border-white/10 text-gray-500 hover:border-white/20 hover:text-gray-300'
|
||||
}`}
|
||||
className="shrink-0 w-20 rounded-md h-8 text-xs font-semibold border disabled:cursor-not-allowed"
|
||||
style={disabled ? {
|
||||
borderColor: 'var(--panel-border)',
|
||||
color: 'var(--text-dim)',
|
||||
} : sel.done ? {
|
||||
background: 'var(--selected-bg)',
|
||||
borderColor: 'var(--selected-border)',
|
||||
color: 'var(--accent-bright)',
|
||||
} : {
|
||||
borderColor: 'var(--btn-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
{sel.done ? '완료' : '미완료'}
|
||||
</button>
|
||||
|
|
@ -78,42 +90,59 @@ export default function WeeklyDefault({ weekly, onChange, totalWeekly, totalMont
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl mx-auto rounded-2xl border border-white/10 bg-gray-900/60 p-6 space-y-4">
|
||||
<div
|
||||
className="max-w-3xl mx-auto rounded-2xl border p-6 space-y-4"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
boxShadow: 'var(--panel-shadow)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-lg font-semibold text-emerald-300">주간 보스 설정</div>
|
||||
<div className="text-lg font-semibold" style={{ color: 'var(--accent-bright)' }}>주간 보스 설정</div>
|
||||
<div className="text-sm tabular-nums">
|
||||
{mode === 'weekly' ? (
|
||||
<>
|
||||
<span className="text-emerald-300 font-semibold">{totalWeekly}</span>
|
||||
<span className="text-gray-500 mx-1">+</span>
|
||||
<span className="text-amber-300 font-semibold">{totalMonthly}</span>
|
||||
<span className="text-gray-500 mx-1">/</span>
|
||||
<span className="text-gray-300 font-semibold">{(remaining ?? 0).toLocaleString()}</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>{totalWeekly}</span>
|
||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>+</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--warning-text-bright)' }}>{totalMonthly}</span>
|
||||
<span className="mx-1" style={{ color: 'var(--text-dim)' }}>/</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--text-emphasis)' }}>{(remaining ?? 0).toLocaleString()}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-emerald-300 font-semibold">+{totalWeekly + totalMonthly}</span>
|
||||
<span className="font-semibold" style={{ color: 'var(--accent-bright)' }}>+{totalWeekly + totalMonthly}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === 'simple' ? (
|
||||
<div className="divide-y divide-white/5">
|
||||
{WEEKLY_BOSSES.map((boss) => (
|
||||
<BossRow
|
||||
<div>
|
||||
{WEEKLY_BOSSES.map((boss, i) => (
|
||||
<div
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
/>
|
||||
className={i > 0 ? 'border-t' : ''}
|
||||
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
|
||||
>
|
||||
<BossRow
|
||||
boss={boss}
|
||||
sel={weekly.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{MONTHLY_BOSSES.map((boss) => (
|
||||
<BossRow
|
||||
<div
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={weekly.blackMage}
|
||||
onChange={updateBlackMage}
|
||||
monthly
|
||||
/>
|
||||
className="border-t"
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<BossRow
|
||||
boss={boss}
|
||||
sel={weekly.blackMage}
|
||||
onChange={updateBlackMage}
|
||||
monthly
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -63,8 +63,13 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
|
|||
return (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<div
|
||||
className={`rounded-md overflow-hidden bg-gray-900 border border-white/5 ${enabled ? '' : 'opacity-30 grayscale'}`}
|
||||
style={{ width: size, height: size }}
|
||||
className={`rounded-md overflow-hidden border ${enabled ? '' : 'opacity-30 grayscale'}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
background: 'var(--surface-nested)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<img src={`${LIBERATION_BOSS_IMAGE_BASE}/${boss.image}`} alt={boss.name} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
|
|
@ -72,9 +77,9 @@ function BossAvatar({ boss, difficulty, size = 40 }) {
|
|||
className="text-[10px] font-bold leading-none rounded flex items-center justify-center border"
|
||||
style={{
|
||||
width: 16, height: 16,
|
||||
color: badge?.color || '#4b5563',
|
||||
color: badge?.color || 'var(--text-dim)',
|
||||
background: badge?.bg || 'transparent',
|
||||
borderColor: badge?.border || 'rgba(255,255,255,0.08)',
|
||||
borderColor: badge?.border || 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
{badge?.label || '-'}
|
||||
|
|
@ -94,17 +99,25 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
|||
const blackmageLocked = monthlyLockedByWeek != null
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-white/5">
|
||||
{WEEKLY_BOSSES.map((boss) => (
|
||||
<BossRow
|
||||
<div>
|
||||
{WEEKLY_BOSSES.map((boss, i) => (
|
||||
<div
|
||||
key={boss.key}
|
||||
boss={boss}
|
||||
sel={config.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
showDone={isCurrent}
|
||||
/>
|
||||
className={i > 0 ? 'border-t' : ''}
|
||||
style={i > 0 ? { borderColor: 'var(--row-divider)' } : undefined}
|
||||
>
|
||||
<BossRow
|
||||
boss={boss}
|
||||
sel={config.bosses[boss.key]}
|
||||
onChange={(patch) => updateBoss(boss.key, patch)}
|
||||
showDone={isCurrent}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className={blackmageLocked ? 'opacity-40 pointer-events-none' : ''}>
|
||||
<div
|
||||
className={`border-t ${blackmageLocked ? 'opacity-40 pointer-events-none' : ''}`}
|
||||
style={{ borderColor: 'var(--row-divider)' }}
|
||||
>
|
||||
<BossRow
|
||||
boss={MONTHLY_BOSSES[0]}
|
||||
sel={blackmageLocked ? { difficulty: 'none', party: 1, done: false } : config.blackMage}
|
||||
|
|
@ -114,7 +127,10 @@ function WeekEditor({ config, onChange, isCurrent, monthlyLockedByWeek }) {
|
|||
/>
|
||||
</div>
|
||||
{blackmageLocked && (
|
||||
<div className="text-[11px] text-amber-400/80 px-3 py-2">
|
||||
<div
|
||||
className="text-[11px] px-3 py-2"
|
||||
style={{ color: 'var(--warning-text)' }}
|
||||
>
|
||||
이번 달 검은 마법사는 {monthlyLockedByWeek}주차에 배정되어 있습니다.
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -210,7 +226,11 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
return (
|
||||
<div
|
||||
key={w.id}
|
||||
className="rounded-xl border border-white/5 bg-gray-950/30"
|
||||
className="rounded-xl border"
|
||||
style={{
|
||||
background: 'var(--surface-3)',
|
||||
borderColor: 'var(--panel-border)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 pl-4 pr-2 py-3">
|
||||
<button
|
||||
|
|
@ -219,11 +239,19 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
className="flex items-center gap-4 flex-1 text-left hover:opacity-90 transition"
|
||||
>
|
||||
<div className="w-12 text-center shrink-0">
|
||||
<div className="text-[11px] text-gray-500 leading-tight">주차</div>
|
||||
<div className="text-xl font-extrabold tabular-nums leading-tight text-gray-200">{n}</div>
|
||||
<div className="text-[11px] leading-tight" style={{ color: 'var(--text-dim)' }}>주차</div>
|
||||
<div
|
||||
className="text-xl font-extrabold tabular-nums leading-tight"
|
||||
style={{ color: 'var(--text-emphasis)' }}
|
||||
>
|
||||
{n}
|
||||
</div>
|
||||
</div>
|
||||
{startDate && (
|
||||
<div className="text-sm text-gray-400 tabular-nums w-24 shrink-0">
|
||||
<div
|
||||
className="text-sm tabular-nums w-24 shrink-0"
|
||||
style={{ color: 'var(--text-muted)' }}
|
||||
>
|
||||
{formatRange(getWeekRange(startDate, n))}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -240,9 +268,9 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
const monthlySum = monthlyLockedByWeek != null ? 0 : bossEarn(MONTHLY_BOSSES[0], w.config.blackMage)
|
||||
return (
|
||||
<div className="text-right shrink-0 pr-1 tabular-nums leading-tight">
|
||||
<div className="text-base font-bold text-emerald-300">+{weeklySum}</div>
|
||||
<div className="text-base font-bold" style={{ color: 'var(--accent-bright)' }}>+{weeklySum}</div>
|
||||
{monthlySum > 0 && (
|
||||
<div className="text-sm font-semibold text-amber-300">+{monthlySum}</div>
|
||||
<div className="text-sm font-semibold" style={{ color: 'var(--warning-text-bright)' }}>+{monthlySum}</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -250,7 +278,8 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
|
||||
<svg
|
||||
width="16" height="16" viewBox="0 0 12 12" fill="none"
|
||||
className={`text-gray-500 transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
className={`transition-transform shrink-0 ${isOpen ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
|
@ -260,7 +289,8 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
onClick={() => removeWeek(w.id)}
|
||||
disabled={weeks.length <= 1}
|
||||
title={weeks.length <= 1 ? '최소 한 주차는 유지되어야 합니다' : '이 주차 삭제'}
|
||||
className="shrink-0 w-8 h-8 rounded-md text-gray-500 hover:text-red-400 hover:bg-red-500/10 disabled:opacity-30 disabled:hover:text-gray-500 disabled:hover:bg-transparent disabled:cursor-not-allowed transition flex items-center justify-center"
|
||||
className="shrink-0 w-8 h-8 rounded-md hover:bg-[var(--danger-bg-hover)] hover:text-[var(--danger-text)] disabled:opacity-30 disabled:hover:bg-transparent disabled:cursor-not-allowed flex items-center justify-center"
|
||||
style={{ color: 'var(--text-dim)' }}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M1 1L13 13M13 1L1 13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
|
|
@ -281,7 +311,13 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
}}
|
||||
style={{ overflow: 'hidden' }}
|
||||
>
|
||||
<div className="border-t border-white/5 px-3 py-3 bg-gray-950/40">
|
||||
<div
|
||||
className="border-t px-3 py-3"
|
||||
style={{
|
||||
borderColor: 'var(--row-divider)',
|
||||
background: 'var(--skeleton-bg)',
|
||||
}}
|
||||
>
|
||||
<WeekEditor
|
||||
config={w.config}
|
||||
onChange={(c) => updateWeek(w.id, c)}
|
||||
|
|
@ -299,7 +335,11 @@ export default function WeeklyScheduler({ startDate, weeks: weeksProp, onChangeW
|
|||
<button
|
||||
type="button"
|
||||
onClick={addWeek}
|
||||
className="w-full rounded-xl border border-dashed border-white/10 hover:border-emerald-500/40 hover:bg-emerald-500/5 text-gray-500 hover:text-emerald-300 py-3 text-sm font-semibold transition flex items-center justify-center gap-2"
|
||||
className="w-full rounded-xl border border-dashed py-3 text-sm font-semibold flex items-center justify-center gap-2 hover:border-[var(--selected-border)] hover:text-[var(--accent-bright)]"
|
||||
style={{
|
||||
borderColor: 'var(--dashed-border)',
|
||||
color: 'var(--text-dim)',
|
||||
}}
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 1V13M1 7H13" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue