@@ -4,12 +4,16 @@ import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-
44import {
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'
1519import { 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
189193const 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 - 9 a - f ] { 3 } ) $ / i. exec ( trimmed )
212+ if ( shorthand ) {
213+ return `#${ shorthand [ 1 ] . split ( '' ) . map ( ( char ) => char + char ) . join ( '' ) . toLowerCase ( ) } `
214+ }
215+ const full = / ^ # ? ( [ 0 - 9 a - 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
476633function 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
484641function 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
9991181function 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