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 { 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 (
|
||||||
|
<ThemeProvider>
|
||||||
<WouterRouter base={basePath}>
|
<WouterRouter base={basePath}>
|
||||||
<AppRoutes />
|
<AppRoutes />
|
||||||
</WouterRouter>
|
</WouterRouter>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
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