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:
parent
29853219bc
commit
dc6f01dd74
7 changed files with 103 additions and 5 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -64,6 +64,10 @@ export default {
|
|||
signedIn: "Angemeldet",
|
||||
signOut: "Abmelden",
|
||||
},
|
||||
theme: {
|
||||
switchToDark: "Dunkelmodus aktivieren",
|
||||
switchToLight: "Hellmodus aktivieren",
|
||||
},
|
||||
actions: {
|
||||
back: "Zurück",
|
||||
cancel: "Abbrechen",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
54
artifacts/skillguard/src/lib/theme.tsx
Normal file
54
artifacts/skillguard/src/lib/theme.tsx
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue