Skip to content

Commit 39dd309

Browse files
authored
Merge pull request #191 from kw-coms/furu002
Add custom color and dark mode controls
2 parents 9fcdbc6 + 99170dd commit 39dd309

2 files changed

Lines changed: 496 additions & 32 deletions

File tree

src/App.jsx

Lines changed: 197 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-
44
import {
55
Binary,
66
Bell,
7+
Check,
78
CircuitBoard,
89
LogOut,
910
Menu,
1011
Megaphone,
12+
Moon,
13+
Palette,
1114
Rocket,
1215
Sparkles,
16+
Sun,
1317
X,
1418
} from 'lucide-react'
1519
import { listNotices } from './services/noticeApi.js'
@@ -141,25 +145,25 @@ const visualDetails = {
141145
title: 'Club OS',
142146
subtitle: 'Study · Build · Share',
143147
rows: ['학습 로드맵', '프로젝트 트랙', '커뮤니티 로그'],
144-
accent: '#0ea5e9',
148+
accent: 'var(--app-accent)',
145149
},
146150
activities: {
147151
title: 'Learning Stack',
148152
subtitle: 'Seminar · Study · Review',
149153
rows: ['기초 세미나', '분야별 스터디', '코드 리뷰'],
150-
accent: '#f43f5e',
154+
accent: 'var(--app-accent)',
151155
},
152156
projects: {
153157
title: 'Project Lab',
154158
subtitle: 'Prototype · Launch · Iterate',
155159
rows: ['서비스 기획', '프론트엔드 구현', '배포와 개선'],
156-
accent: '#8b5cf6',
160+
accent: 'var(--app-accent)',
157161
},
158162
recruit: {
159163
title: 'Join Flow',
160164
subtitle: 'Apply · Meet · Start',
161165
rows: ['지원서 제출', '개별 안내', '오리엔테이션'],
162-
accent: '#0071e3',
166+
accent: 'var(--app-accent)',
163167
},
164168
}
165169

@@ -187,8 +191,66 @@ const sectionMeta = {
187191
}
188192

189193
const floatingBarBaseClass = 'border-b border-black/10 bg-white/82 shadow-[0_1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl supports-[backdrop-filter]:bg-white/72'
190-
const solidActionBtnClass = 'inline-flex min-h-10 items-center justify-center rounded-full bg-[#0071e3] px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-[#0077ed] disabled:cursor-wait disabled:opacity-60'
191-
const ghostActionBtnClass = 'inline-flex min-h-10 items-center justify-center rounded-full border border-[#0071e3]/40 bg-white/70 px-5 py-2.5 text-sm font-semibold text-[#0066cc] transition hover:bg-white disabled:cursor-wait disabled:opacity-60'
194+
const solidActionBtnClass = 'inline-flex min-h-10 items-center justify-center rounded-full bg-[var(--app-accent)] px-5 py-2.5 text-sm font-semibold text-white transition hover:bg-[var(--app-accent-hover)] disabled:cursor-wait disabled:opacity-60'
195+
const ghostActionBtnClass = 'inline-flex min-h-10 items-center justify-center rounded-full border border-[color:var(--app-accent-border)] bg-white/70 px-5 py-2.5 text-sm font-semibold text-[var(--app-accent-text)] transition hover:bg-white disabled:cursor-wait disabled:opacity-60'
196+
197+
const DEFAULT_ACCENT = '#0071e3'
198+
const THEME_MODE_KEY = 'kwcoms-theme-mode'
199+
const ACCENT_COLOR_KEY = 'kwcoms-accent-color'
200+
const accentSwatches = [
201+
{ name: 'Apple Blue', value: '#0071e3' },
202+
{ name: 'Graphite', value: '#3c3c43' },
203+
{ name: 'Rose', value: '#d70015' },
204+
{ name: 'Amber', value: '#ff9f0a' },
205+
{ name: 'Violet', value: '#8e5cf7' },
206+
]
207+
208+
function normalizeHex(value) {
209+
if (typeof value !== 'string') return DEFAULT_ACCENT
210+
const trimmed = value.trim()
211+
const shorthand = /^#?([0-9a-f]{3})$/i.exec(trimmed)
212+
if (shorthand) {
213+
return `#${shorthand[1].split('').map((char) => char + char).join('').toLowerCase()}`
214+
}
215+
const full = /^#?([0-9a-f]{6})$/i.exec(trimmed)
216+
return full ? `#${full[1].toLowerCase()}` : DEFAULT_ACCENT
217+
}
218+
219+
function hexToRgb(hex) {
220+
const value = normalizeHex(hex).slice(1)
221+
return {
222+
r: parseInt(value.slice(0, 2), 16),
223+
g: parseInt(value.slice(2, 4), 16),
224+
b: parseInt(value.slice(4, 6), 16),
225+
}
226+
}
227+
228+
function rgbToHex({ r, g, b }) {
229+
return `#${[r, g, b].map((value) => Math.round(value).toString(16).padStart(2, '0')).join('')}`
230+
}
231+
232+
function mixHex(base, overlay, overlayRatio) {
233+
const baseRgb = hexToRgb(base)
234+
const overlayRgb = hexToRgb(overlay)
235+
const ratio = Math.min(Math.max(overlayRatio, 0), 1)
236+
return rgbToHex({
237+
r: baseRgb.r * (1 - ratio) + overlayRgb.r * ratio,
238+
g: baseRgb.g * (1 - ratio) + overlayRgb.g * ratio,
239+
b: baseRgb.b * (1 - ratio) + overlayRgb.b * ratio,
240+
})
241+
}
242+
243+
function getStoredThemeMode() {
244+
if (typeof window === 'undefined') return 'light'
245+
const stored = window.localStorage.getItem(THEME_MODE_KEY)
246+
if (stored === 'light' || stored === 'dark') return stored
247+
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
248+
}
249+
250+
function getStoredAccentColor() {
251+
if (typeof window === 'undefined') return DEFAULT_ACCENT
252+
return normalizeHex(window.localStorage.getItem(ACCENT_COLOR_KEY) || DEFAULT_ACCENT)
253+
}
192254

193255
// ─── Auth guards ───────────────────────────────────────────────────────────
194256

@@ -442,7 +504,7 @@ function NotificationButton({ alignLeft = false, padded = false }) {
442504
{effectiveOpen && createPortal(
443505
<div
444506
ref={dropdownRef}
445-
className="fixed z-[9999] w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-lg border border-black/10 bg-white text-[var(--theme-body-dark)] shadow-2xl"
507+
className="theme-popover fixed z-[9999] w-[min(22rem,calc(100vw-2rem))] overflow-hidden rounded-lg border border-black/10 bg-white text-[var(--theme-body-dark)] shadow-2xl"
446508
style={dropdownStyle}
447509
>
448510
<div className="flex items-center justify-between border-b border-black/10 px-4 py-3">
@@ -471,20 +533,140 @@ function NotificationButton({ alignLeft = false, padded = false }) {
471533
)
472534
}
473535

536+
function AppearanceControl({ accentColor, setAccentColor, themeMode, setThemeMode }) {
537+
const [open, setOpen] = useState(false)
538+
const panelRef = useRef(null)
539+
const accent = normalizeHex(accentColor)
540+
const isDark = themeMode === 'dark'
541+
542+
useEffect(() => {
543+
if (!open) return undefined
544+
const handlePointerDown = (event) => {
545+
if (panelRef.current?.contains(event.target)) return
546+
setOpen(false)
547+
}
548+
document.addEventListener('pointerdown', handlePointerDown)
549+
return () => document.removeEventListener('pointerdown', handlePointerDown)
550+
}, [open])
551+
552+
return (
553+
<div ref={panelRef} className="appearance-control fixed bottom-4 right-4 z-[90] flex flex-col items-end gap-3 sm:bottom-5 sm:right-5">
554+
{open && (
555+
<div className="appearance-panel w-[min(22rem,calc(100vw-2rem))] rounded-2xl border border-black/10 bg-white/88 p-3 text-[#1d1d1f] shadow-[0_24px_70px_rgba(0,0,0,0.16)] backdrop-blur-2xl">
556+
<div className="flex items-center justify-between gap-3 px-1 pb-2">
557+
<div>
558+
<p className="text-[11px] font-bold uppercase tracking-[0.12em] text-[#86868b]">Appearance</p>
559+
<h2 className="text-sm font-semibold">테마 설정</h2>
560+
</div>
561+
<button
562+
type="button"
563+
onClick={() => setThemeMode(isDark ? 'light' : 'dark')}
564+
className="inline-flex min-h-10 items-center gap-2 rounded-full bg-[#f5f5f7] px-3 text-xs font-bold text-[#1d1d1f] transition hover:bg-white"
565+
aria-label={isDark ? '라이트 모드로 전환' : '다크 모드로 전환'}
566+
>
567+
{isDark ? <Sun size={15} /> : <Moon size={15} />}
568+
{isDark ? 'Light' : 'Dark'}
569+
</button>
570+
</div>
571+
572+
<div className="rounded-xl bg-[#f5f5f7] p-3">
573+
<div className="mb-3 flex items-center justify-between gap-3">
574+
<span className="text-xs font-semibold text-[#6e6e73]">컬러</span>
575+
<span className="rounded-full bg-white px-2.5 py-1 font-mono text-[11px] font-bold text-[#6e6e73]">{accent.toUpperCase()}</span>
576+
</div>
577+
<div className="grid grid-cols-5 gap-2">
578+
{accentSwatches.map((swatch) => {
579+
const active = accent === swatch.value
580+
return (
581+
<button
582+
key={swatch.value}
583+
type="button"
584+
onClick={() => setAccentColor(swatch.value)}
585+
className="relative flex aspect-square items-center justify-center rounded-full border border-black/10 shadow-[inset_0_1px_0_rgba(255,255,255,0.45)]"
586+
style={{ backgroundColor: swatch.value }}
587+
aria-label={`${swatch.name} 색상 선택`}
588+
title={swatch.name}
589+
>
590+
{active && <Check size={17} className="text-white drop-shadow" />}
591+
</button>
592+
)
593+
})}
594+
</div>
595+
<div className="mt-3 flex items-center gap-2">
596+
<label className="inline-flex min-h-10 flex-1 cursor-pointer items-center justify-between gap-3 rounded-full border border-black/10 bg-white px-3 text-xs font-bold text-[#1d1d1f]">
597+
직접 선택
598+
<input
599+
type="color"
600+
value={accent}
601+
onChange={(event) => setAccentColor(event.target.value)}
602+
className="h-7 w-9 cursor-pointer rounded-full border-0 bg-transparent p-0"
603+
aria-label="커스텀 색상 선택"
604+
/>
605+
</label>
606+
<button
607+
type="button"
608+
onClick={() => setAccentColor(DEFAULT_ACCENT)}
609+
className="min-h-10 rounded-full border border-black/10 bg-white px-3 text-xs font-bold text-[#6e6e73] transition hover:text-[#1d1d1f]"
610+
>
611+
Reset
612+
</button>
613+
</div>
614+
</div>
615+
</div>
616+
)}
617+
618+
<button
619+
type="button"
620+
onClick={() => setOpen((value) => !value)}
621+
className="appearance-trigger inline-flex size-12 items-center justify-center rounded-full border border-black/10 bg-white/82 text-[#1d1d1f] shadow-[0_16px_38px_rgba(0,0,0,0.14)] backdrop-blur-xl transition hover:-translate-y-0.5 hover:bg-white"
622+
aria-label="테마 설정 열기"
623+
aria-expanded={open}
624+
>
625+
<Palette size={20} />
626+
</button>
627+
</div>
628+
)
629+
}
630+
474631
// ─── Root router ────────────────────────────────────────────────────────────
475632

476633
function PageFallback() {
477634
return (
478-
<div className="flex min-h-screen items-center justify-center bg-[#f5f5f7]">
479-
<div className="h-8 w-8 animate-spin rounded-full border-4 border-black/10 border-t-[#0071e3]" />
635+
<div className="flex min-h-screen items-center justify-center bg-[var(--app-bg)]">
636+
<div className="h-8 w-8 animate-spin rounded-full border-4 border-black/10 border-t-[var(--app-accent)]" />
480637
</div>
481638
)
482639
}
483640

484641
function App() {
642+
const [themeMode, setThemeMode] = useState(getStoredThemeMode)
643+
const [accentColor, setAccentColor] = useState(getStoredAccentColor)
644+
645+
useEffect(() => {
646+
const root = document.documentElement
647+
const mode = themeMode === 'dark' ? 'dark' : 'light'
648+
const accent = normalizeHex(accentColor)
649+
650+
root.dataset.themeMode = mode
651+
root.style.setProperty('--app-accent', accent)
652+
root.style.setProperty('--app-accent-hover', mixHex(accent, mode === 'dark' ? '#ffffff' : '#000000', mode === 'dark' ? 0.14 : 0.08))
653+
root.style.setProperty('--app-accent-soft', mixHex(accent, mode === 'dark' ? '#17191f' : '#ffffff', mode === 'dark' ? 0.8 : 0.88))
654+
root.style.setProperty('--app-accent-text', mode === 'dark' ? mixHex(accent, '#ffffff', 0.28) : mixHex(accent, '#000000', 0.08))
655+
root.style.setProperty('--app-accent-border', `color-mix(in srgb, ${accent} 42%, transparent)`)
656+
657+
window.localStorage.setItem(THEME_MODE_KEY, mode)
658+
window.localStorage.setItem(ACCENT_COLOR_KEY, accent)
659+
}, [accentColor, themeMode])
660+
485661
return (
486662
<Suspense fallback={<PageFallback />}>
487663
<ScrollToTop />
664+
<AppearanceControl
665+
accentColor={accentColor}
666+
setAccentColor={setAccentColor}
667+
themeMode={themeMode}
668+
setThemeMode={setThemeMode}
669+
/>
488670
<Routes>
489671
<Route path="/" element={<HomeView />} />
490672
<Route path="/notices" element={<NoticesPage />} />
@@ -764,7 +946,7 @@ function HomeView() {
764946
}
765947

766948
return (
767-
<div className="relative min-h-screen bg-[#f5f5f7] text-[#1d1d1f] selection:bg-[#0071e3]/20 selection:text-[#1d1d1f]">
949+
<div className="theme-home relative min-h-screen bg-[var(--app-bg)] text-[var(--app-text)] selection:bg-[var(--app-accent-soft)] selection:text-[var(--app-text)]">
768950

769951
<header className="fixed inset-x-0 top-0 z-60">
770952
<div className={`${floatingBarBaseClass} relative mx-auto flex h-12 items-center justify-between gap-4 px-4 sm:px-6 lg:px-8`}>
@@ -781,7 +963,7 @@ function HomeView() {
781963
return (
782964
<button key={tab.id} type="button" onClick={() => openPanel(tab.id)} className={`relative px-1 text-xs font-semibold transition ${active ? 'text-[#1d1d1f]' : 'text-[#1d1d1f]/72 hover:text-[#1d1d1f]'}`}>
783965
{tab.label}
784-
<span className={`absolute -bottom-4 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-[#0071e3] transition ${active ? 'opacity-100' : 'opacity-0'}`} />
966+
<span className={`absolute -bottom-4 left-1/2 h-0.5 w-4 -translate-x-1/2 rounded-full bg-[var(--app-accent)] transition ${active ? 'opacity-100' : 'opacity-0'}`} />
785967
</button>
786968
)
787969
})}
@@ -807,7 +989,7 @@ function HomeView() {
807989
type="button"
808990
onClick={() => navigate('/login')}
809991
disabled={authLoading}
810-
className="ml-auto hidden shrink-0 whitespace-nowrap rounded-full bg-[#0071e3] px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-[#0077ed] disabled:cursor-wait disabled:opacity-70 md:inline-flex"
992+
className="ml-auto hidden shrink-0 whitespace-nowrap rounded-full bg-[var(--app-accent)] px-4 py-1.5 text-xs font-semibold text-white transition hover:bg-[var(--app-accent-hover)] disabled:cursor-wait disabled:opacity-70 md:inline-flex"
811993
>
812994
로그인
813995
</button>
@@ -895,7 +1077,7 @@ function HomeView() {
8951077
<div className="absolute inset-x-0 bottom-0 h-1/2 bg-linear-to-b from-transparent to-white/85" />
8961078
<div className="relative z-10 mx-auto max-w-7xl">
8971079
<div className="mx-auto inline-flex items-center gap-2 rounded-full bg-white/76 px-4 py-2 text-xs font-semibold text-[#6e6e73] shadow-[0_6px_22px_rgba(0,0,0,0.06)] backdrop-blur-xl">
898-
<span className="size-2 rounded-full bg-[#0071e3]" />
1080+
<span className="size-2 rounded-full bg-[var(--app-accent)]" />
8991081
2026 Semester Ready
9001082
</div>
9011083
<div className="relative mx-auto mt-8 flex h-36 w-36 items-center justify-center sm:h-44 sm:w-44">
@@ -923,7 +1105,7 @@ function HomeView() {
9231105
</div>
9241106
{latestNotice && (
9251107
<button type="button" onClick={goNotices} className="mx-auto mt-7 flex max-w-md items-center gap-2 rounded-full bg-white px-4 py-2 text-left shadow-[0_2px_12px_rgba(0,0,0,0.08)] transition hover:shadow-[0_5px_18px_rgba(0,0,0,0.12)]">
926-
<Megaphone size={14} className="shrink-0 text-[#0071e3]" />
1108+
<Megaphone size={14} className="shrink-0 text-[var(--app-accent-text)]" />
9271109
<span className="truncate text-xs font-semibold text-[#1d1d1f]">{latestNotice.title}</span>
9281110
<span className="ml-auto shrink-0 text-[10px] font-bold uppercase text-[#0066cc]">공지</span>
9291111
</button>
@@ -998,7 +1180,7 @@ function HomeView() {
9981180

9991181
function PageShell({ children, wide = false, full = false }) {
10001182
return (
1001-
<div className="apple-route relative min-h-screen bg-[#f5f5f7] text-[#1d1d1f] selection:bg-[#0071e3]/20 selection:text-[#1d1d1f]">
1183+
<div className="apple-route relative min-h-screen bg-[var(--app-bg)] text-[var(--app-text)] selection:bg-[var(--app-accent-soft)] selection:text-[var(--app-text)]">
10021184
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-white via-[#f5f5f7] to-white" />
10031185
<div className="fixed right-4 top-4 z-50">
10041186
<NotificationButton />

0 commit comments

Comments
 (0)