Add dark mode toggle to public and admin layouts

Implements a persistent dark/light mode toggle throughout the app.

Changes:
- New src/lib/theme.tsx: ThemeProvider + useTheme hook; reads from
  localStorage (key: skillguard-theme), falls back to system preference,
  and toggles .dark class on <html> element.
- App.tsx: wrapped with ThemeProvider at the root.
- public-layout.tsx: Sun/Moon icon button added to header nav (after
  LanguageSwitcher), with tooltip text.
- layout.tsx: Sun/Moon icon button added to sidebar footer (below sign-out),
  with tooltip side="right".
- All three locale files (de/en/es): added common.theme.switchToDark and
  common.theme.switchToLight tooltip keys.
This commit is contained in:
Replit Agent 2026-06-18 12:57:08 +00:00
parent 29853219bc
commit dc6f01dd74
7 changed files with 103 additions and 5 deletions

View file

@ -2,6 +2,7 @@ import { Switch, Route, Router as WouterRouter } from "wouter";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster"; import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip"; import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/lib/theme";
import { PublicLayout } from "@/components/public-layout"; import { PublicLayout } from "@/components/public-layout";
import { AppLayout } from "@/components/layout"; import { AppLayout } from "@/components/layout";
import { RequireAdmin } from "@/components/require-admin"; import { RequireAdmin } from "@/components/require-admin";
@ -99,9 +100,11 @@ function AppRoutes() {
function App() { function App() {
return ( return (
<WouterRouter base={basePath}> <ThemeProvider>
<AppRoutes /> <WouterRouter base={basePath}>
</WouterRouter> <AppRoutes />
</WouterRouter>
</ThemeProvider>
); );
} }

View file

@ -1,10 +1,13 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink } from "lucide-react"; import { Shield, LayoutDashboard, History, Settings, LogOut, ExternalLink, Sun, Moon } from "lucide-react";
import { useGetMe } from "@workspace/api-client-react"; import { useGetMe } from "@workspace/api-client-react";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar"; import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { useTheme } from "@/lib/theme";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const basePath = import.meta.env.BASE_URL.replace(/\/$/, ""); const basePath = import.meta.env.BASE_URL.replace(/\/$/, "");
@ -13,6 +16,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: me } = useGetMe(); const { data: me } = useGetMe();
const qc = useQueryClient(); const qc = useQueryClient();
const { theme, toggleTheme } = useTheme();
async function handleSignOut() { async function handleSignOut() {
await fetch(`${basePath}/api/auth/logout`, { await fetch(`${basePath}/api/auth/logout`, {
@ -98,6 +102,18 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
<div className="mt-2 flex justify-start px-1">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={toggleTheme} className="h-8 w-8 text-sidebar-foreground/70 hover:text-sidebar-foreground">
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{t(theme === "dark" ? "common.theme.switchToLight" : "common.theme.switchToDark")}
</TooltipContent>
</Tooltip>
</div>
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>

View file

@ -1,8 +1,10 @@
import { Link, useLocation } from "wouter"; import { Link, useLocation } from "wouter";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Shield, ShieldCheck, Settings } from "lucide-react"; import { Shield, ShieldCheck, Settings, Sun, Moon } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { LanguageSwitcher } from "@/components/language-switcher"; import { LanguageSwitcher } from "@/components/language-switcher";
import { useTheme } from "@/lib/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const CATALOG_ANCHOR_ID = "skill-katalog"; const CATALOG_ANCHOR_ID = "skill-katalog";
@ -18,6 +20,7 @@ function scrollToCatalog(attempts = 20) {
export function PublicLayout({ children }: { children: React.ReactNode }) { export function PublicLayout({ children }: { children: React.ReactNode }) {
const [location, setLocation] = useLocation(); const [location, setLocation] = useLocation();
const { t } = useTranslation(); const { t } = useTranslation();
const { theme, toggleTheme } = useTheme();
const handleCatalogClick = (e: React.MouseEvent) => { const handleCatalogClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
@ -62,6 +65,16 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
</Link> </Link>
</Button> </Button>
<LanguageSwitcher className="ml-1 w-[7.5rem]" /> <LanguageSwitcher className="ml-1 w-[7.5rem]" />
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" onClick={toggleTheme} className="ml-1 h-8 w-8">
{theme === "dark" ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
</TooltipTrigger>
<TooltipContent>
{t(theme === "dark" ? "common.theme.switchToLight" : "common.theme.switchToDark")}
</TooltipContent>
</Tooltip>
</nav> </nav>
</div> </div>
</header> </header>

View file

@ -64,6 +64,10 @@ export default {
signedIn: "Angemeldet", signedIn: "Angemeldet",
signOut: "Abmelden", signOut: "Abmelden",
}, },
theme: {
switchToDark: "Dunkelmodus aktivieren",
switchToLight: "Hellmodus aktivieren",
},
actions: { actions: {
back: "Zurück", back: "Zurück",
cancel: "Abbrechen", cancel: "Abbrechen",

View file

@ -64,6 +64,10 @@ export default {
signedIn: "Signed in", signedIn: "Signed in",
signOut: "Sign out", signOut: "Sign out",
}, },
theme: {
switchToDark: "Switch to dark mode",
switchToLight: "Switch to light mode",
},
actions: { actions: {
back: "Back", back: "Back",
cancel: "Cancel", cancel: "Cancel",

View file

@ -64,6 +64,10 @@ export default {
signedIn: "Sesión iniciada", signedIn: "Sesión iniciada",
signOut: "Cerrar sesión", signOut: "Cerrar sesión",
}, },
theme: {
switchToDark: "Activar modo oscuro",
switchToLight: "Activar modo claro",
},
actions: { actions: {
back: "Atrás", back: "Atrás",
cancel: "Cancelar", cancel: "Cancelar",

View file

@ -0,0 +1,54 @@
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
const STORAGE_KEY = "skillguard-theme";
function getInitialTheme(): Theme {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "dark" || stored === "light") return stored;
} catch {}
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
function applyTheme(theme: Theme) {
document.documentElement.classList.toggle("dark", theme === "dark");
}
interface ThemeContextValue {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextValue>({
theme: "light",
toggleTheme: () => {},
});
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const t = getInitialTheme();
applyTheme(t);
return t;
});
useEffect(() => {
applyTheme(theme);
try { localStorage.setItem(STORAGE_KEY, theme); } catch {}
}, [theme]);
function toggleTheme() {
setTheme(t => (t === "dark" ? "light" : "dark"));
}
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
return useContext(ThemeContext);
}