Theme-Toggle: Dark/Light-Mode pro Nutzer (localStorage, System-Default, FOUC-Schutz)

This commit is contained in:
Kühn
2026-09-10 17:34:40 +02:00
parent d43d1a3f34
commit 6ee8582ce5
3 changed files with 83 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
import React, { useState, useEffect } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider, useAuth } from './context/AuthContext';
import { ThemeProvider } from './context/ThemeContext';
import { ToastProvider, useToast } from './components/Toast';
import { setGlobalErrorHandler } from './utils/api';
import AuthModal from './components/AuthModal';
@@ -139,11 +140,13 @@ function AppContent() {
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ToastProvider>
<AppContent />
</ToastProvider>
</AuthProvider>
<ThemeProvider>
<AuthProvider>
<ToastProvider>
<AppContent />
</ToastProvider>
</AuthProvider>
</ThemeProvider>
</QueryClientProvider>
);
}

View File

@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { useAuth } from '../context/AuthContext';
import { useTheme } from '../context/ThemeContext';
// Punkt 5: SVG Icons für jeden Tab
const tabIcons = {
@@ -22,6 +23,7 @@ const tabIcons = {
export default function Sidebar({ activeTab, onTabChange }) {
const { user, logout } = useAuth();
const { isDark, toggleTheme } = useTheme();
// Punkt 19: Collapsible Sidebar State
const [collapsed, setCollapsed] = useState(false);
@@ -101,6 +103,26 @@ export default function Sidebar({ activeTab, onTabChange }) {
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" /></svg>
</button>
)}
{/* Theme-Toggle: Dark/Light-Mode, individuelle Einstellung pro Nutzer (localStorage) */}
{!collapsed && (
<button className="btn btn-ghost btn-sm w-full justify-start gap-2 mt-2" onClick={toggleTheme} title={isDark ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}>
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
<span>{isDark ? 'Helles Design' : 'Dunkles Design'}</span>
</button>
)}
{collapsed && (
<button className="btn btn-ghost btn-xs btn-square" onClick={toggleTheme} title={isDark ? 'Zu hellem Design wechseln' : 'Zu dunklem Design wechseln'}>
{isDark ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" /></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
)}
</button>
)}
</div>
</aside>
</div>

View File

@@ -0,0 +1,53 @@
import React, { createContext, useContext, useEffect, useState } from 'react';
/**
* Theme-Context: Dark/Light-Mode-Umschaltung für daisyUI (data-theme auf <html>).
* - Persistenz in localStorage ('workflow_theme': 'dark' | 'light')
* - Ohne gespeicherte Wahl folgt das Theme der Systemeinstellung (prefers-color-scheme)
* - FOUC-Schutz: initiales data-theme wird synchron vor dem ersten Render gesetzt
*/
const STORAGE_KEY = 'workflow_theme';
// Synchron beim Modul-Load ausführen — verhindert einen Theme-Flash beim Start
function resolveInitialTheme() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved === 'dark' || saved === 'light') return saved;
} catch (e) { /* localStorage nicht verfügbar — Fallback unten */ }
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
return 'light';
}
// Direkt beim Laden das Theme setzen (vor React-Render)
const initialTheme = resolveInitialTheme();
document.documentElement.setAttribute('data-theme', initialTheme);
const ThemeContext = createContext(null);
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState(initialTheme);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
try {
localStorage.setItem(STORAGE_KEY, theme);
} catch (e) { /* Speichern fehlgeschlagen — Theme bleibt für diese Session aktiv */ }
}, [theme]);
const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme, isDark: theme === 'dark' }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}