From dc6f01dd7423267d53b7933ef20d91aeeebbdb4a Mon Sep 17 00:00:00 2001 From: Replit Agent Date: Thu, 18 Jun 2026 12:57:08 +0000 Subject: [PATCH] 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 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. --- artifacts/skillguard/src/App.tsx | 9 ++-- .../skillguard/src/components/layout.tsx | 18 ++++++- .../src/components/public-layout.tsx | 15 +++++- .../skillguard/src/i18n/locales/de/common.ts | 4 ++ .../skillguard/src/i18n/locales/en/common.ts | 4 ++ .../skillguard/src/i18n/locales/es/common.ts | 4 ++ artifacts/skillguard/src/lib/theme.tsx | 54 +++++++++++++++++++ 7 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 artifacts/skillguard/src/lib/theme.tsx diff --git a/artifacts/skillguard/src/App.tsx b/artifacts/skillguard/src/App.tsx index 43b324c..af80756 100644 --- a/artifacts/skillguard/src/App.tsx +++ b/artifacts/skillguard/src/App.tsx @@ -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 ( - - - + + + + + ); } diff --git a/artifacts/skillguard/src/components/layout.tsx b/artifacts/skillguard/src/components/layout.tsx index 3a14f95..b536db1 100644 --- a/artifacts/skillguard/src/components/layout.tsx +++ b/artifacts/skillguard/src/components/layout.tsx @@ -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 }) { +
+ + + + + + {t(theme === "dark" ? "common.theme.switchToLight" : "common.theme.switchToDark")} + + +
diff --git a/artifacts/skillguard/src/components/public-layout.tsx b/artifacts/skillguard/src/components/public-layout.tsx index 5ac384f..2ee7fef 100644 --- a/artifacts/skillguard/src/components/public-layout.tsx +++ b/artifacts/skillguard/src/components/public-layout.tsx @@ -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 }) { + + + + + + {t(theme === "dark" ? "common.theme.switchToLight" : "common.theme.switchToDark")} + + diff --git a/artifacts/skillguard/src/i18n/locales/de/common.ts b/artifacts/skillguard/src/i18n/locales/de/common.ts index 530e0b4..ee2ade0 100644 --- a/artifacts/skillguard/src/i18n/locales/de/common.ts +++ b/artifacts/skillguard/src/i18n/locales/de/common.ts @@ -64,6 +64,10 @@ export default { signedIn: "Angemeldet", signOut: "Abmelden", }, + theme: { + switchToDark: "Dunkelmodus aktivieren", + switchToLight: "Hellmodus aktivieren", + }, actions: { back: "Zurück", cancel: "Abbrechen", diff --git a/artifacts/skillguard/src/i18n/locales/en/common.ts b/artifacts/skillguard/src/i18n/locales/en/common.ts index 17b31ca..6fb2cb6 100644 --- a/artifacts/skillguard/src/i18n/locales/en/common.ts +++ b/artifacts/skillguard/src/i18n/locales/en/common.ts @@ -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", diff --git a/artifacts/skillguard/src/i18n/locales/es/common.ts b/artifacts/skillguard/src/i18n/locales/es/common.ts index 068e0f5..db109de 100644 --- a/artifacts/skillguard/src/i18n/locales/es/common.ts +++ b/artifacts/skillguard/src/i18n/locales/es/common.ts @@ -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", diff --git a/artifacts/skillguard/src/lib/theme.tsx b/artifacts/skillguard/src/lib/theme.tsx new file mode 100644 index 0000000..88ee2af --- /dev/null +++ b/artifacts/skillguard/src/lib/theme.tsx @@ -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({ + theme: "light", + toggleTheme: () => {}, +}); + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useState(() => { + 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 ( + + {children} + + ); +} + +export function useTheme() { + return useContext(ThemeContext); +}