해방 날짜 계산기 + 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:
caadiq 2026-04-18 12:36:58 +09:00
parent f0a04c51ff
commit fe65c107c8
6 changed files with 367 additions and 180 deletions

View file

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

View file

@ -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" />

View file

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

View file

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

View file

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

View file

@ -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" />