feat: 라이트/다크 모드 테마 인프라 구축

- ThemeContext.jsx: 테마 상태 관리, localStorage 저장, 시스템 테마 감지
- index.css: CSS 변수 시스템 (다크/라이트 테마 색상)
- tailwind.config.js: darkMode: 'class' 설정
- App.jsx: ThemeProvider 래핑
- Sidebar.jsx: 테마 토글 버튼 (시스템/다크/라이트 순환)

아직 페이지 컴포넌트의 하드코딩 색상 수정 필요
This commit is contained in:
caadiq 2025-12-31 19:08:47 +09:00
parent 3c22f62c65
commit 91d751fc93
5 changed files with 168 additions and 7 deletions

View file

@ -3,6 +3,7 @@ import { Routes, Route, useLocation } from 'react-router-dom';
import { AnimatePresence, motion } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion';
import DeviceLayout, { useIsMobile } from './components/DeviceLayout'; import DeviceLayout, { useIsMobile } from './components/DeviceLayout';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import { ThemeProvider } from './contexts/ThemeContext';
import ServerDetail from './pages/ServerDetail'; import ServerDetail from './pages/ServerDetail';
import WorldsPage from './pages/WorldsPage'; import WorldsPage from './pages/WorldsPage';
import PlayersPage from './pages/PlayersPage'; import PlayersPage from './pages/PlayersPage';
@ -46,6 +47,7 @@ function App() {
// //
if (isAuthPage) { if (isAuthPage) {
return ( return (
<ThemeProvider>
<DeviceLayout> <DeviceLayout>
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30"> <div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div> <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
@ -58,12 +60,14 @@ function App() {
</AnimatePresence> </AnimatePresence>
</div> </div>
</DeviceLayout> </DeviceLayout>
</ThemeProvider>
); );
} }
// / // /
if (isStandalonePage) { if (isStandalonePage) {
return ( return (
<ThemeProvider>
<DeviceLayout> <DeviceLayout>
<div className="bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30"> <div className="bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div> <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
@ -75,10 +79,12 @@ function App() {
</AnimatePresence> </AnimatePresence>
</div> </div>
</DeviceLayout> </DeviceLayout>
</ThemeProvider>
); );
} }
return ( return (
<ThemeProvider>
<DeviceLayout> <DeviceLayout>
<div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30"> <div className="min-h-screen bg-mc-bg text-gray-200 font-sans selection:bg-mc-green/30">
<div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div> <div className="absolute inset-0 bg-[url('https://www.transparenttextures.com/patterns/cubes.png')] opacity-[0.03] pointer-events-none"></div>
@ -105,6 +111,7 @@ function App() {
</div> </div>
</div> </div>
</DeviceLayout> </DeviceLayout>
</ThemeProvider>
); );
} }

View file

@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect } from 'react'; import React, { useState, useRef, useEffect } from 'react';
import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom'; import { NavLink, useLocation, Link, useNavigate } from 'react-router-dom';
import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2, Package } from 'lucide-react'; import { Home, Globe, Users, Menu, X, Gamepad2, Map, Shield, LogIn, LogOut, User, Settings, BarChart2, Package, Sun, Moon, Monitor } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import { useTheme } from '../contexts/ThemeContext';
import { io } from 'socket.io-client'; import { io } from 'socket.io-client';
// //
@ -16,6 +17,7 @@ const Sidebar = ({ isMobile = false }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { isLoggedIn, isAdmin, user, logout, checkAuth } = useAuth(); const { isLoggedIn, isAdmin, user, logout, checkAuth } = useAuth();
const { theme, cycleTheme, isDark } = useTheme();
const profileMenuRef = useRef(null); const profileMenuRef = useRef(null);
const menuItems = [ const menuItems = [
@ -259,18 +261,18 @@ const Sidebar = ({ isMobile = false }) => {
initial={{ opacity: 0, scale: 0.9 }} initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }} exit={{ opacity: 0, scale: 0.9 }}
className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-zinc-900 border border-zinc-700 rounded-2xl p-6 z-[101] shadow-2xl" className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-2xl p-6 z-[101] shadow-2xl"
> >
<div className="text-center"> <div className="text-center">
<div className="w-12 h-12 mx-auto mb-4 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center"> <div className="w-12 h-12 mx-auto mb-4 rounded-full bg-red-500/10 border border-red-500/20 flex items-center justify-center">
<LogOut className="w-6 h-6 text-red-400" /> <LogOut className="w-6 h-6 text-red-400" />
</div> </div>
<h3 className="text-lg font-bold text-white mb-2">로그아웃</h3> <h3 className="text-lg font-bold text-[var(--text-primary)] mb-2">로그아웃</h3>
<p className="text-sm text-zinc-400 mb-6">정말 로그아웃 하시겠습니까?</p> <p className="text-sm text-[var(--text-secondary)] mb-6">정말 로그아웃 하시겠습니까?</p>
<div className="flex gap-3"> <div className="flex gap-3">
<button <button
onClick={() => setShowLogoutDialog(false)} onClick={() => setShowLogoutDialog(false)}
className="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-xl font-medium transition-colors" className="flex-1 py-2.5 bg-[var(--bg-tertiary)] hover:bg-[var(--bg-hover)] text-[var(--text-secondary)] rounded-xl font-medium transition-colors"
> >
취소 취소
</button> </button>
@ -288,6 +290,32 @@ const Sidebar = ({ isMobile = false }) => {
</AnimatePresence> </AnimatePresence>
); );
//
const ThemeToggle = () => {
const getThemeIcon = () => {
if (theme === 'system') return <Monitor size={18} />;
if (theme === 'dark') return <Moon size={18} />;
return <Sun size={18} />;
};
const getThemeLabel = () => {
if (theme === 'system') return '시스템';
if (theme === 'dark') return '다크';
return '라이트';
};
return (
<button
onClick={cycleTheme}
className="w-full flex items-center gap-3 px-4 py-3 mx-4 mb-2 rounded-xl text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors"
style={{ width: 'calc(100% - 32px)' }}
>
{getThemeIcon()}
<span className="font-medium">테마: {getThemeLabel()}</span>
</button>
);
};
// ( ) // ( )
const ProfileSection = () => ( const ProfileSection = () => (
<div className="relative p-4 border-t border-white/10" ref={profileMenuRef}> <div className="relative p-4 border-t border-white/10" ref={profileMenuRef}>
@ -578,6 +606,9 @@ const Sidebar = ({ isMobile = false }) => {
})} })}
</nav> </nav>
{/* 테마 토글 */}
<ThemeToggle />
{/* 하단: 로그인/유저 정보 */} {/* 하단: 로그인/유저 정보 */}
{isLoggedIn ? ( {isLoggedIn ? (
<ProfileSection /> <ProfileSection />

View file

@ -0,0 +1,81 @@
import React, { createContext, useContext, useState, useEffect } from "react";
// : 'system' | 'dark' | 'light'
const ThemeContext = createContext();
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};
export const ThemeProvider = ({ children }) => {
// localStorage (: system)
const [theme, setTheme] = useState(() => {
const saved = localStorage.getItem("theme");
return saved || "system";
});
// ( )
const [resolvedTheme, setResolvedTheme] = useState("dark");
//
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const updateResolvedTheme = () => {
if (theme === "system") {
setResolvedTheme(mediaQuery.matches ? "dark" : "light");
} else {
setResolvedTheme(theme);
}
};
updateResolvedTheme();
//
const handler = () => updateResolvedTheme();
mediaQuery.addEventListener("change", handler);
return () => mediaQuery.removeEventListener("change", handler);
}, [theme]);
// <html>
useEffect(() => {
const root = document.documentElement;
root.classList.remove("light", "dark");
root.classList.add(resolvedTheme);
}, [resolvedTheme]);
// localStorage
const setThemeWithStorage = (newTheme) => {
setTheme(newTheme);
localStorage.setItem("theme", newTheme);
};
// : system -> dark -> light -> system
const cycleTheme = () => {
const order = ["system", "dark", "light"];
const currentIndex = order.indexOf(theme);
const nextIndex = (currentIndex + 1) % order.length;
setThemeWithStorage(order[nextIndex]);
};
return (
<ThemeContext.Provider
value={{
theme, // (system | dark | light)
resolvedTheme, // (dark | light)
setTheme: setThemeWithStorage,
cycleTheme,
isDark: resolvedTheme === "dark",
}}
>
{children}
</ThemeContext.Provider>
);
};
export default ThemeContext;

View file

@ -2,14 +2,55 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* 기본 html, body 스타일 - 다크 배경 & 스크롤바 레이아웃 고정 */ /* 테마 CSS 변수 */
:root {
/* 다크 테마 (기본) */
--bg-primary: #141414;
--bg-secondary: #1c1c1c;
--bg-tertiary: #252525;
--bg-card: #1c1c1c;
--bg-hover: rgba(255, 255, 255, 0.05);
--text-primary: #ffffff;
--text-secondary: #a1a1aa;
--text-tertiary: #71717a;
--text-muted: #52525b;
--border-color: rgba(255, 255, 255, 0.1);
--border-subtle: rgba(255, 255, 255, 0.05);
--shadow-color: rgba(0, 0, 0, 0.3);
}
/* 라이트 테마 */
.light {
--bg-primary: #f8fafc;
--bg-secondary: #ffffff;
--bg-tertiary: #f1f5f9;
--bg-card: #ffffff;
--bg-hover: rgba(0, 0, 0, 0.05);
--text-primary: #18181b;
--text-secondary: #52525b;
--text-tertiary: #71717a;
--text-muted: #a1a1aa;
--border-color: rgba(0, 0, 0, 0.1);
--border-subtle: rgba(0, 0, 0, 0.05);
--shadow-color: rgba(0, 0, 0, 0.1);
}
/* 기본 html, body 스타일 */
html { html {
scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */ scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */
} }
body { body {
background: #141414; background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh; min-height: 100vh;
transition: background-color 0.3s ease, color 0.3s ease;
} }
/* PC 레이아웃 - min-width 적용 */ /* PC 레이아웃 - min-width 적용 */

View file

@ -1,6 +1,7 @@
/** @type {import('tailwindcss').Config} */ /** @type {import('tailwindcss').Config} */
export default { export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"], content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
darkMode: "class", // 클래스 기반 다크 모드
theme: { theme: {
extend: { extend: {
fontFamily: { fontFamily: {