feat: 라이트/다크 모드 테마 인프라 구축
- ThemeContext.jsx: 테마 상태 관리, localStorage 저장, 시스템 테마 감지 - index.css: CSS 변수 시스템 (다크/라이트 테마 색상) - tailwind.config.js: darkMode: 'class' 설정 - App.jsx: ThemeProvider 래핑 - Sidebar.jsx: 테마 토글 버튼 (시스템/다크/라이트 순환) 아직 페이지 컴포넌트의 하드코딩 색상 수정 필요
This commit is contained in:
parent
3c22f62c65
commit
91d751fc93
5 changed files with 168 additions and 7 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 />
|
||||||
|
|
|
||||||
81
frontend/src/contexts/ThemeContext.jsx
Normal file
81
frontend/src/contexts/ThemeContext.jsx
Normal 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;
|
||||||
|
|
@ -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 적용 */
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue