Implement theme switching and enhance dark mode support (#43)

This commit is contained in:
samanhappy
2025-05-02 12:40:11 +08:00
committed by GitHub
parent 9ca242a0e4
commit 10d4616601
18 changed files with 261 additions and 53 deletions

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext'; import { ToastProvider } from './contexts/ToastContext';
import { ThemeProvider } from './contexts/ThemeContext';
import MainLayout from './layouts/MainLayout'; import MainLayout from './layouts/MainLayout';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
@@ -13,31 +14,33 @@ import MarketPage from './pages/MarketPage';
function App() { function App() {
return ( return (
<AuthProvider> <ThemeProvider>
<ToastProvider> <AuthProvider>
<Router> <ToastProvider>
<Routes> <Router>
{/* 公共路由 */} <Routes>
<Route path="/login" element={<LoginPage />} /> {/* 公共路由 */}
<Route path="/login" element={<LoginPage />} />
{/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<ProtectedRoute />}> {/* 受保护的路由,使用 MainLayout 作为布局容器 */}
<Route element={<MainLayout />}> <Route element={<ProtectedRoute />}>
<Route path="/" element={<DashboardPage />} /> <Route element={<MainLayout />}>
<Route path="/servers" element={<ServersPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/groups" element={<GroupsPage />} /> <Route path="/servers" element={<ServersPage />} />
<Route path="/market" element={<MarketPage />} /> <Route path="/groups" element={<GroupsPage />} />
<Route path="/market/:serverName" element={<MarketPage />} /> <Route path="/market" element={<MarketPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/market/:serverName" element={<MarketPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Route>
</Route> </Route>
</Route>
{/* 未匹配的路由重定向到首页 */}
{/* 未匹配的路由重定向到首页 */} <Route path="*" element={<Navigate to="/" />} />
<Route path="*" element={<Navigate to="/" />} /> </Routes>
</Routes> </Router>
</Router> </ToastProvider>
</ToastProvider> </AuthProvider>
</AuthProvider> </ThemeProvider>
); );
} }

View File

@@ -65,7 +65,7 @@ const AddGroupForm = ({ onAdd, onCancel }: AddGroupFormProps) => {
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6"> <div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2> <h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.addNew')}</h2>

View File

@@ -74,7 +74,7 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
</button> </button>
{modalVisible && ( {modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm <ServerForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={toggleModal} onCancel={toggleModal}

View File

@@ -82,7 +82,7 @@ const EditGroupForm = ({ group, onEdit, onCancel }: EditGroupFormProps) => {
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-lg shadow-lg max-w-md w-full"> <div className="bg-white rounded-lg shadow-lg max-w-md w-full">
<div className="p-6"> <div className="p-6">
<h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2> <h2 className="text-xl font-semibold text-gray-800 mb-4">{t('groups.edit')}</h2>

View File

@@ -61,7 +61,7 @@ const EditServerForm = ({ server, onEdit, onCancel }: EditServerFormProps) => {
} }
return ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm <ServerForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={onCancel} onCancel={onCancel}

View File

@@ -270,7 +270,7 @@ const MarketServerDetail: React.FC<MarketServerDetailProps> = ({
</div> </div>
{modalVisible && ( {modalVisible && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<ServerForm <ServerForm
onSubmit={handleSubmit} onSubmit={handleSubmit}
onCancel={toggleModal} onCancel={toggleModal}

View File

@@ -6,8 +6,8 @@ interface ContentProps {
const Content: React.FC<ContentProps> = ({ children }) => { const Content: React.FC<ContentProps> = ({ children }) => {
return ( return (
<main className="flex-1 p-6 overflow-auto"> <main className="flex-1 overflow-auto p-6 bg-gray-100 dark:bg-gray-900">
<div className="max-w-5xl mx-auto"> <div className="container mx-auto">
{children} {children}
</div> </div>
</main> </main>

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
interface HeaderProps { interface HeaderProps {
onToggleSidebar: () => void; onToggleSidebar: () => void;
@@ -18,13 +19,13 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
}; };
return ( return (
<header className="bg-white shadow-sm z-10"> <header className="bg-white dark:bg-gray-800 shadow-sm z-10">
<div className="flex justify-between items-center px-4 py-3"> <div className="flex justify-between items-center px-4 py-3">
<div className="flex items-center"> <div className="flex items-center">
{/* 侧边栏切换按钮 */} {/* 侧边栏切换按钮 */}
<button <button
onClick={onToggleSidebar} onClick={onToggleSidebar}
className="p-2 rounded-md text-gray-500 hover:text-gray-900 hover:bg-gray-100 focus:outline-none" className="p-2 rounded-md text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none"
aria-label={t('app.toggleSidebar')} aria-label={t('app.toggleSidebar')}
> >
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -33,13 +34,16 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
</button> </button>
{/* 应用标题 */} {/* 应用标题 */}
<h1 className="ml-4 text-xl font-bold text-gray-900">{t('app.title')}</h1> <h1 className="ml-4 text-xl font-bold text-gray-900 dark:text-white">{t('app.title')}</h1>
</div> </div>
{/* 用户信息和操作 */} {/* 用户信息和操作 */}
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{/* Theme Switch */}
<ThemeSwitch />
{auth.user && ( {auth.user && (
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700 dark:text-gray-300">
{t('app.welcomeUser', { username: auth.user.username })} {t('app.welcomeUser', { username: auth.user.username })}
</span> </span>
)} )}
@@ -47,7 +51,7 @@ const Header: React.FC<HeaderProps> = ({ onToggleSidebar }) => {
<div className="flex space-x-2"> <div className="flex space-x-2">
<button <button
onClick={handleLogout} onClick={handleLogout}
className="px-3 py-1.5 bg-red-100 text-red-800 rounded hover:bg-red-200 text-sm" className="px-3 py-1.5 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-100 rounded hover:bg-red-200 dark:hover:bg-red-800 text-sm"
> >
{t('app.logout')} {t('app.logout')}
</button> </button>

View File

@@ -68,7 +68,7 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
return ( return (
<aside <aside
className={`bg-white shadow-sm transition-all duration-300 ease-in-out ${ className={`bg-white dark:bg-gray-800 shadow-sm transition-all duration-300 ease-in-out ${
collapsed ? 'w-16' : 'w-64' collapsed ? 'w-16' : 'w-64'
}`} }`}
> >
@@ -80,8 +80,8 @@ const Sidebar: React.FC<SidebarProps> = ({ collapsed }) => {
className={({ isActive }) => className={({ isActive }) =>
`flex items-center px-3 py-2 rounded-md transition-colors ${ `flex items-center px-3 py-2 rounded-md transition-colors ${
isActive isActive
? 'bg-blue-100 text-blue-800' ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
: 'text-gray-700 hover:bg-gray-100' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}` }`
} }
end={item.path === '/'} end={item.path === '/'}

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/contexts/ThemeContext';
import { Sun, Moon, Monitor } from 'lucide-react';
const ThemeSwitch: React.FC = () => {
const { t } = useTranslation();
const { theme, setTheme } = useTheme();
return (
<div className="flex items-center space-x-2">
<div className="flex bg-gray-200 dark:bg-gray-700 rounded-lg p-1">
<button
onClick={() => setTheme('light')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'light'
? 'bg-white text-yellow-600 shadow'
: 'text-black dark:text-gray-300 hover:text-yellow-600 dark:hover:text-yellow-500'
}`}
title={t('theme.light')}
aria-label={t('theme.light')}
>
<Sun size={18} />
</button>
<button
onClick={() => setTheme('dark')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'dark'
? 'bg-gray-800 text-blue-400 shadow'
: 'text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400'
}`}
title={t('theme.dark')}
aria-label={t('theme.dark')}
>
<Moon size={18} />
</button>
{/* <button
onClick={() => setTheme('system')}
className={`flex items-center justify-center rounded-md p-1.5 ${theme === 'system'
? 'bg-white dark:bg-gray-800 text-green-600 dark:text-green-400 shadow'
: 'text-black dark:text-gray-300 hover:text-green-600 dark:hover:text-green-400'
}`}
title={t('theme.system')}
aria-label={t('theme.system')}
>
<Monitor size={18} />
</button> */}
</div>
</div>
);
};
export default ThemeSwitch;

View File

@@ -0,0 +1,76 @@
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark'; // The actual theme used after resolving system preference
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const useTheme = () => {
const context = useContext(ThemeContext);
if (!context) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
};
export const ThemeProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
// Get theme from localStorage or default to 'system'
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem('theme') as Theme;
return savedTheme || 'system';
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>('light');
// Function to set theme and save to localStorage
const handleSetTheme = (newTheme: Theme) => {
setTheme(newTheme);
localStorage.setItem('theme', newTheme);
};
// Effect to handle system theme changes and apply theme to document
useEffect(() => {
const updateTheme = () => {
const root = window.document.documentElement;
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
// Determine which theme to use
const themeToApply = theme === 'system' ? systemTheme : theme;
setResolvedTheme(themeToApply as 'light' | 'dark');
// Apply or remove dark class based on theme
if (themeToApply === 'dark') {
console.log('Applying dark mode to HTML root element'); // 添加日志
root.classList.add('dark');
document.body.style.backgroundColor = '#111827'; // Force a dark background to ensure visible effect
} else {
console.log('Removing dark mode from HTML root element'); // 添加日志
root.classList.remove('dark');
document.body.style.backgroundColor = ''; // Reset background color
}
};
// Set up listeners for system theme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
mediaQuery.addEventListener('change', updateTheme);
// Initial theme setup
updateTheme();
// Cleanup
return () => {
mediaQuery.removeEventListener('change', updateTheme);
};
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme: handleSetTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};

View File

@@ -1,4 +1,4 @@
/* Use standard Tailwind directives */ /* Use project's custom Tailwind import */
@import "tailwindcss"; @import "tailwindcss";
/* Add some custom styles to verify CSS is working correctly */ /* Add some custom styles to verify CSS is working correctly */
@@ -11,6 +11,52 @@ body {
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/* Dark mode override styles - these will apply when dark class is on html element */
.dark body {
background-color: #111827;
color: #e5e7eb;
}
.dark .bg-white {
background-color: #1f2937 !important;
}
.dark .text-gray-900 {
color: #f9fafb !important;
}
.dark .text-gray-800 {
color: #f3f4f6 !important;
}
.dark .text-gray-700 {
color: #e5e7eb !important;
}
.dark .text-gray-600 {
color: #d1d5db !important;
}
.dark .text-gray-500 {
color: #9ca3af !important;
}
.dark .border-gray-300 {
border-color: #4b5563 !important;
}
.dark .bg-gray-100 {
background-color: #374151 !important;
}
.dark .bg-gray-50 {
background-color: #1f2937 !important;
}
.dark .shadow {
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.4), 0 1px 2px 0 rgba(0, 0, 0, 0.24) !important;
}
.bg-custom-blue { .bg-custom-blue {
background-color: #4a90e2; background-color: #4a90e2;
} }

View File

@@ -13,7 +13,7 @@ const MainLayout: React.FC = () => {
}; };
return ( return (
<div className="flex flex-col min-h-screen bg-gray-100"> <div className="flex flex-col min-h-screen bg-gray-100 dark:bg-gray-900">
{/* 顶部导航 */} {/* 顶部导航 */}
<Header onToggleSidebar={toggleSidebar} /> <Header onToggleSidebar={toggleSidebar} />

View File

@@ -12,6 +12,12 @@
"welcomeUser": "Welcome, {{username}}", "welcomeUser": "Welcome, {{username}}",
"name": "MCP Hub" "name": "MCP Hub"
}, },
"theme": {
"title": "Theme",
"light": "Light",
"dark": "Dark",
"system": "System"
},
"auth": { "auth": {
"login": "Login", "login": "Login",
"loginTitle": "Login to MCP Hub", "loginTitle": "Login to MCP Hub",

View File

@@ -12,6 +12,12 @@
"welcomeUser": "欢迎, {{username}}", "welcomeUser": "欢迎, {{username}}",
"name": "MCP Hub" "name": "MCP Hub"
}, },
"theme": {
"title": "主题",
"light": "浅色",
"dark": "深色",
"system": "系统"
},
"auth": { "auth": {
"login": "登录", "login": "登录",
"loginTitle": "登录 MCP Hub", "loginTitle": "登录 MCP Hub",

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import ThemeSwitch from '@/components/ui/ThemeSwitch';
const LoginPage: React.FC = () => { const LoginPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -39,10 +40,13 @@ const LoginPage: React.FC = () => {
}; };
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8"> <div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
<div className="absolute top-4 right-4">
<ThemeSwitch />
</div>
<div className="max-w-md w-full space-y-8"> <div className="max-w-md w-full space-y-8">
<div> <div>
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900"> <h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900 dark:text-white">
{t('auth.loginTitle')} {t('auth.loginTitle')}
</h2> </h2>
</div> </div>
@@ -58,7 +62,7 @@ const LoginPage: React.FC = () => {
type="text" type="text"
autoComplete="username" autoComplete="username"
required required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-t-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('auth.username')} placeholder={t('auth.username')}
value={username} value={username}
onChange={(e) => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
@@ -74,7 +78,7 @@ const LoginPage: React.FC = () => {
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
required required
className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm" className="appearance-none rounded-none relative block w-full px-3 py-2 border border-gray-300 dark:border-gray-700 placeholder-gray-500 dark:placeholder-gray-400 text-gray-900 dark:text-white dark:bg-gray-800 rounded-b-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 focus:z-10 sm:text-sm"
placeholder={t('auth.password')} placeholder={t('auth.password')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
@@ -83,7 +87,7 @@ const LoginPage: React.FC = () => {
</div> </div>
{error && ( {error && (
<div className="text-red-500 text-sm text-center">{error}</div> <div className="text-red-500 dark:text-red-400 text-sm text-center">{error}</div>
)} )}
<div> <div>

View File

@@ -51,13 +51,13 @@ const SettingsPage: React.FC = () => {
}; };
return ( return (
<div className="max-w-4xl mx-auto"> <div className="container mx-auto">
<h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1> <h1 className="text-2xl font-bold text-gray-900 mb-8">{t('pages.settings.title')}</h1>
{/* Language Settings */} {/* Language Settings */}
<div className="bg-white shadow rounded-lg p-6 mb-6"> <div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.language')}</h2> <h2 className="font-semibold text-gray-800">{t('pages.settings.language')}</h2>
<div className="flex space-x-3"> <div className="flex space-x-3">
<button <button
className={`px-3 py-1.5 rounded-md transition-colors text-sm ${ className={`px-3 py-1.5 rounded-md transition-colors text-sm ${
@@ -84,12 +84,12 @@ const SettingsPage: React.FC = () => {
</div> </div>
{/* Route Configuration Settings */} {/* Route Configuration Settings */}
<div className="bg-white shadow rounded-lg p-6 mb-6"> <div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('routingConfig')} onClick={() => toggleSection('routingConfig')}
> >
<h2 className="text-xl font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2> <h2 className="font-semibold text-gray-800">{t('pages.settings.routeConfig')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">
{sectionsVisible.routingConfig ? '▼' : '►'} {sectionsVisible.routingConfig ? '▼' : '►'}
</span> </span>
@@ -125,12 +125,12 @@ const SettingsPage: React.FC = () => {
</div> </div>
{/* Change Password */} {/* Change Password */}
<div className="bg-white shadow rounded-lg p-6 mb-6"> <div className="bg-white shadow rounded-lg py-4 px-6 mb-6">
<div <div
className="flex justify-between items-center cursor-pointer" className="flex justify-between items-center cursor-pointer"
onClick={() => toggleSection('password')} onClick={() => toggleSection('password')}
> >
<h2 className="text-xl font-semibold text-gray-800">{t('auth.changePassword')}</h2> <h2 className="font-semibold text-gray-800">{t('auth.changePassword')}</h2>
<span className="text-gray-500"> <span className="text-gray-500">
{sectionsVisible.password ? '▼' : '►'} {sectionsVisible.password ? '▼' : '►'}
</span> </span>

View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
darkMode: 'class', // Use class strategy for dark mode
theme: {
extend: {},
},
plugins: [],
}