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 { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { ThemeProvider } from "@/lib/theme";
import { PublicLayout } from "@/components/public-layout";
import { AppLayout } from "@/components/layout";
import { RequireAdmin } from "@/components/require-admin";
@ -99,9 +100,11 @@ function AppRoutes() {
function App() {
return (
<ThemeProvider>
<WouterRouter base={basePath}>
<AppRoutes />
</WouterRouter>
</ThemeProvider>
);
}

View file

@ -1,10 +1,13 @@
import { Link, useLocation } from "wouter";
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 { useQueryClient } from "@tanstack/react-query";
import { Sidebar, SidebarContent, SidebarHeader, SidebarMenu, SidebarMenuItem, SidebarMenuButton, SidebarProvider, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarFooter } from "@/components/ui/sidebar";
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(/\/$/, "");
@ -13,6 +16,7 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
const { t } = useTranslation();
const { data: me } = useGetMe();
const qc = useQueryClient();
const { theme, toggleTheme } = useTheme();
async function handleSignOut() {
await fetch(`${basePath}/api/auth/logout`, {
@ -98,6 +102,18 @@ export function AppLayout({ children }: { children: React.ReactNode }) {
</SidebarMenuButton>
</SidebarMenuItem>
</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>
</Sidebar>

View file

@ -1,8 +1,10 @@
import { Link, useLocation } from "wouter";
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 { LanguageSwitcher } from "@/components/language-switcher";
import { useTheme } from "@/lib/theme";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
const CATALOG_ANCHOR_ID = "skill-katalog";
@ -18,6 +20,7 @@ function scrollToCatalog(attempts = 20) {
export function PublicLayout({ children }: { children: React.ReactNode }) {
const [location, setLocation] = useLocation();
const { t } = useTranslation();
const { theme, toggleTheme } = useTheme();
const handleCatalogClick = (e: React.MouseEvent) => {
e.preventDefault();
@ -62,6 +65,16 @@ export function PublicLayout({ children }: { children: React.ReactNode }) {
</Link>
</Button>
<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>
</div>
</header>

View file

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

View file

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

View file

@ -64,6 +64,10 @@ export default {
signedIn: "Sesión iniciada",
signOut: "Cerrar sesión",
},
theme: {
switchToDark: "Activar modo oscuro",
switchToLight: "Activar modo claro",
},
actions: {
back: "Atrás",
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);
}