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 DeviceLayout, { useIsMobile } from './components/DeviceLayout';
|
||||
import Sidebar from './components/Sidebar';
|
||||
import { ThemeProvider } from './contexts/ThemeContext';
|
||||
import ServerDetail from './pages/ServerDetail';
|
||||
import WorldsPage from './pages/WorldsPage';
|
||||
import PlayersPage from './pages/PlayersPage';
|
||||
|
|
@ -46,6 +47,7 @@ function App() {
|
|||
// 인증 페이지는 사이드바 없이 렌더링
|
||||
if (isAuthPage) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<DeviceLayout>
|
||||
<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>
|
||||
|
|
@ -58,12 +60,14 @@ function App() {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// 관리자/프로필 페이지는 별도 레이아웃
|
||||
if (isStandalonePage) {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<DeviceLayout>
|
||||
<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>
|
||||
|
|
@ -75,10 +79,12 @@ function App() {
|
|||
</AnimatePresence>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<DeviceLayout>
|
||||
<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>
|
||||
|
|
@ -105,6 +111,7 @@ function App() {
|
|||
</div>
|
||||
</div>
|
||||
</DeviceLayout>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useState, useRef, useEffect } from 'react';
|
||||
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 { useAuth } from '../contexts/AuthContext';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { io } from 'socket.io-client';
|
||||
|
||||
// 사이드바 네비게이션 컴포넌트
|
||||
|
|
@ -16,6 +17,7 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { isLoggedIn, isAdmin, user, logout, checkAuth } = useAuth();
|
||||
const { theme, cycleTheme, isDark } = useTheme();
|
||||
const profileMenuRef = useRef(null);
|
||||
|
||||
const menuItems = [
|
||||
|
|
@ -259,18 +261,18 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
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="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" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-white mb-2">로그아웃</h3>
|
||||
<p className="text-sm text-zinc-400 mb-6">정말 로그아웃 하시겠습니까?</p>
|
||||
<h3 className="text-lg font-bold text-[var(--text-primary)] mb-2">로그아웃</h3>
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-6">정말 로그아웃 하시겠습니까?</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
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>
|
||||
|
|
@ -288,6 +290,32 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
</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 = () => (
|
||||
<div className="relative p-4 border-t border-white/10" ref={profileMenuRef}>
|
||||
|
|
@ -578,6 +606,9 @@ const Sidebar = ({ isMobile = false }) => {
|
|||
})}
|
||||
</nav>
|
||||
|
||||
{/* 테마 토글 */}
|
||||
<ThemeToggle />
|
||||
|
||||
{/* 하단: 로그인/유저 정보 */}
|
||||
{isLoggedIn ? (
|
||||
<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 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 {
|
||||
scrollbar-gutter: stable; /* 스크롤바 공간 예약 (세로 스크롤바) */
|
||||
}
|
||||
|
||||
body {
|
||||
background: #141414;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-height: 100vh;
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
/* PC 레이아웃 - min-width 적용 */
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
||||
darkMode: "class", // 클래스 기반 다크 모드
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue