mirror of
https://github.com/steveiliop56/tinyauth.git
synced 2025-11-05 08:35:44 +00:00
feat: add support for light mode
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
53
frontend/src/components/providers/theme-provider.tsx
Normal file
53
frontend/src/components/providers/theme-provider.tsx
Normal 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;
|
||||||
|
};
|
||||||
23
frontend/src/components/theme-switch/theme-switch.tsx
Normal file
23
frontend/src/components/theme-switch/theme-switch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user