feat: add support for light mode

This commit is contained in:
Stavros
2025-10-30 22:23:57 +02:00
parent 0227af6d2b
commit f5f18bc2f6
9 changed files with 111 additions and 26 deletions

View File

@@ -12,7 +12,7 @@
<link rel="manifest" href="/site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<title>Tinyauth</title> <title>Tinyauth</title>
</head> </head>
<body class="dark"> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>

View File

@@ -18,9 +18,10 @@ export const LanguageSelector = () => {
setLanguage(option as SupportedLanguage); setLanguage(option as SupportedLanguage);
i18n.changeLanguage(option as SupportedLanguage); i18n.changeLanguage(option as SupportedLanguage);
}; };
return ( return (
<Select onValueChange={handleSelect} value={language}> <Select onValueChange={handleSelect} value={language}>
<SelectTrigger className="absolute top-5 right-5"> <SelectTrigger className="bg-card">
<SelectValue placeholder="Select language" /> <SelectValue placeholder="Select language" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@@ -3,6 +3,7 @@ import { LanguageSelector } from "../language/language";
import { Outlet } from "react-router"; import { Outlet } from "react-router";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { DomainWarning } from "../domain-warning/domain-warning"; import { DomainWarning } from "../domain-warning/domain-warning";
import { ThemeSwitch } from "../theme-switch/theme-switch";
const BaseLayout = ({ children }: { children: React.ReactNode }) => { const BaseLayout = ({ children }: { children: React.ReactNode }) => {
const { backgroundImage, title } = useAppContext(); const { backgroundImage, title } = useAppContext();
@@ -20,7 +21,10 @@ const BaseLayout = ({ children }: { children: React.ReactNode }) => {
backgroundPosition: "center", backgroundPosition: "center",
}} }}
> >
<LanguageSelector /> <div className="absolute top-5 right-5 flex flex-row gap-2">
<ThemeSwitch />
<LanguageSelector />
</div>
{children} {children}
</div> </div>
); );

View File

@@ -0,0 +1,53 @@
import React from "react";
import { createContext, useEffect, useState } from "react";
interface ThemeSchema {
darkMode: boolean;
setDarkMode: (darkMode: boolean) => void;
}
const ThemeContext = createContext<ThemeSchema | null>(null);
export const ThemeProvider = ({ children }: { children: React.ReactNode }) => {
const [darkMode, setDarkMode] = useState<boolean>(false);
useEffect(() => {
const storedTheme = localStorage.getItem("tinyauth-theme");
if (storedTheme) {
setDarkMode(storedTheme === "dark");
return;
}
const prefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
setDarkMode(prefersDark);
}, []);
useEffect(() => {
const rootElement = document.documentElement;
rootElement.classList.remove("dark", "light");
rootElement.classList.add(darkMode ? "dark" : "light");
}, [darkMode]);
const values = {
darkMode,
setDarkMode: (darkMode: boolean) => {
localStorage.setItem("tinyauth-theme", darkMode ? "dark" : "light");
setDarkMode(darkMode);
},
};
return (
<ThemeContext.Provider value={values}>{children}</ThemeContext.Provider>
);
};
export const useTheme = () => {
const context = React.useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
};

View File

@@ -0,0 +1,23 @@
import { MoonIcon, SunIcon } from "lucide-react";
import { useTheme } from "../providers/theme-provider";
import { Button } from "../ui/button";
export const ThemeSwitch = () => {
const { darkMode, setDarkMode } = useTheme();
const toggleTheme = () => {
setDarkMode(!darkMode);
};
return (
<Button
className="bg-card hover:bg-card/90 text-card-foreground"
aria-label={`Switch to ${darkMode ? "light" : "dark"} mode`}
onClick={() => {
toggleTheme();
}}
>
{darkMode ? <SunIcon /> : <MoonIcon />}
</Button>
);
};

View File

@@ -6,7 +6,7 @@ import { cn } from "@/lib/utils";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive hover:cursor-pointer",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -35,7 +35,7 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "hover:cursor-pointer border-input data-[placeholder]:text-card-foreground [&_svg:not([class*='text-'])]:text-card-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
{...props} {...props}

View File

@@ -1,8 +1,9 @@
import { useTheme } from "next-themes"; import { useTheme } from "../providers/theme-provider";
import { Toaster as Sonner, ToasterProps } from "sonner"; import { Toaster as Sonner, ToasterProps } from "sonner";
const Toaster = ({ ...props }: ToasterProps) => { const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme(); const { darkMode } = useTheme();
const theme = darkMode ? "dark" : "light";
return ( return (
<Sonner <Sonner

View File

@@ -16,6 +16,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AppContextProvider } from "./context/app-context.tsx"; import { AppContextProvider } from "./context/app-context.tsx";
import { UserContextProvider } from "./context/user-context.tsx"; import { UserContextProvider } from "./context/user-context.tsx";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import { ThemeProvider } from "./components/providers/theme-provider.tsx";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -24,25 +25,27 @@ createRoot(document.getElementById("root")!).render(
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AppContextProvider> <AppContextProvider>
<UserContextProvider> <UserContextProvider>
<BrowserRouter> <ThemeProvider>
<Routes> <BrowserRouter>
<Route element={<Layout />} errorElement={<ErrorPage />}> <Routes>
<Route path="/" element={<App />} /> <Route element={<Layout />} errorElement={<ErrorPage />}>
<Route path="/login" element={<LoginPage />} /> <Route path="/" element={<App />} />
<Route path="/logout" element={<LogoutPage />} /> <Route path="/login" element={<LoginPage />} />
<Route path="/continue" element={<ContinuePage />} /> <Route path="/logout" element={<LogoutPage />} />
<Route path="/totp" element={<TotpPage />} /> <Route path="/continue" element={<ContinuePage />} />
<Route <Route path="/totp" element={<TotpPage />} />
path="/forgot-password" <Route
element={<ForgotPasswordPage />} path="/forgot-password"
/> element={<ForgotPasswordPage />}
<Route path="/unauthorized" element={<UnauthorizedPage />} /> />
<Route path="/error" element={<ErrorPage />} /> <Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="*" element={<NotFoundPage />} /> <Route path="/error" element={<ErrorPage />} />
</Route> <Route path="*" element={<NotFoundPage />} />
</Routes> </Route>
</BrowserRouter> </Routes>
<Toaster /> </BrowserRouter>
<Toaster />
</ThemeProvider>
</UserContextProvider> </UserContextProvider>
</AppContextProvider> </AppContextProvider>
</QueryClientProvider> </QueryClientProvider>