feat: add oidc support with authentik
This commit is contained in:
@@ -6,6 +6,7 @@ import { useEffect } from "react";
|
||||
|
||||
export function AuthRedirect() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
// const session = { user: null }; const isPending = false;
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -4,6 +4,10 @@ import Script from "next/script";
|
||||
import { env } from "~/env";
|
||||
|
||||
export function UmamiScript() {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!env.NEXT_PUBLIC_UMAMI_WEBSITE_ID || !env.NEXT_PUBLIC_UMAMI_SCRIPT_URL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useRouter } from "next/navigation";
|
||||
|
||||
export function Navbar() {
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
// const session = { user: null } as any; const isPending = false;
|
||||
const [isMobileNavOpen, setIsMobileNavOpen] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ interface SidebarProps {
|
||||
export function Sidebar({ mobile, onClose }: SidebarProps) {
|
||||
const pathname = usePathname();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
// const session = { user: null } as any; const isPending = false;
|
||||
const { isCollapsed, toggleCollapse } = useSidebar();
|
||||
|
||||
// If mobile, always expanded
|
||||
|
||||
@@ -16,6 +16,7 @@ interface SidebarTriggerProps {
|
||||
export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
const pathname = usePathname();
|
||||
const { isPending } = authClient.useSession();
|
||||
// const isPending = false;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -71,8 +72,8 @@ export function SidebarTrigger({ isOpen, onToggle }: SidebarTriggerProps) {
|
||||
pathname === link.href ? "page" : undefined
|
||||
}
|
||||
className={`flex items-center gap-3 px-3 py-2.5 text-sm font-medium transition-colors ${pathname === link.href
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted"
|
||||
? "bg-primary/10 text-primary"
|
||||
: "text-foreground hover:bg-muted"
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
|
||||
@@ -53,7 +53,6 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
|
||||
type AnimationPreferences = {
|
||||
prefersReducedMotion: boolean;
|
||||
@@ -175,15 +174,16 @@ export function AnimationPreferencesProvider({
|
||||
autoSync = true,
|
||||
}: AnimationPreferencesProviderProps) {
|
||||
const updateMutation = api.settings.updateAnimationPreferences.useMutation();
|
||||
const { data: session } = authClient.useSession();
|
||||
const isAuthed = !!session?.user;
|
||||
// Server query only when authenticated
|
||||
|
||||
// Server query - tRPC will handle authentication internally
|
||||
// The query will only succeed if the user is authenticated
|
||||
const { data: serverPrefs } = api.settings.getAnimationPreferences.useQuery(
|
||||
undefined,
|
||||
{
|
||||
enabled: isAuthed,
|
||||
enabled: true, // Let tRPC handle auth
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 60_000,
|
||||
retry: false, // Don't retry if not authenticated
|
||||
},
|
||||
);
|
||||
|
||||
@@ -279,7 +279,7 @@ export function AnimationPreferencesProvider({
|
||||
// Optionally sync to server
|
||||
const shouldSync = opts?.sync ?? autoSync;
|
||||
|
||||
if (shouldSync && isAuthed) {
|
||||
if (shouldSync && serverPrefs) { // If serverPrefs exists, user is authenticated
|
||||
pendingSyncRef.current = {
|
||||
prefersReducedMotion: patch.prefersReducedMotion,
|
||||
animationSpeedMultiplier: patch.animationSpeedMultiplier,
|
||||
@@ -315,7 +315,7 @@ export function AnimationPreferencesProvider({
|
||||
animationSpeedMultiplier,
|
||||
autoSync,
|
||||
updateMutation,
|
||||
isAuthed,
|
||||
serverPrefs,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -323,7 +323,7 @@ export function AnimationPreferencesProvider({
|
||||
useEffect(() => {
|
||||
if (!isHydratedRef.current) return;
|
||||
if (serverHydratedRef.current) return;
|
||||
if (!isAuthed || !serverPrefs) return;
|
||||
if (!serverPrefs) return; // No server prefs means not authenticated or not loaded yet
|
||||
|
||||
const localIsDefault =
|
||||
prefersReducedMotion === DEFAULT_PREFERS_REDUCED &&
|
||||
@@ -348,7 +348,6 @@ export function AnimationPreferencesProvider({
|
||||
performUpdate,
|
||||
prefersReducedMotion,
|
||||
animationSpeedMultiplier,
|
||||
isAuthed,
|
||||
]);
|
||||
|
||||
const updatePreferences = useCallback<
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "./theme-provider";
|
||||
import { generateAccentColors } from "~/lib/color-utils";
|
||||
import { api } from "~/trpc/react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
|
||||
type ColorTheme = "slate" | "blue" | "green" | "rose" | "orange" | "custom";
|
||||
|
||||
interface ColorThemeContextType {
|
||||
colorTheme: ColorTheme;
|
||||
setColorTheme: (theme: ColorTheme, customColor?: string) => void;
|
||||
customColor?: string;
|
||||
}
|
||||
|
||||
const ColorThemeContext = React.createContext<
|
||||
ColorThemeContextType | undefined
|
||||
>(undefined);
|
||||
|
||||
export function useColorTheme() {
|
||||
const context = React.useContext(ColorThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useColorTheme must be used within a ColorThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
interface ColorThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
defaultColorTheme?: ColorTheme;
|
||||
}
|
||||
|
||||
export function ColorThemeProvider({
|
||||
children,
|
||||
defaultColorTheme = "slate",
|
||||
}: ColorThemeProviderProps) {
|
||||
const [colorTheme, setColorThemeState] =
|
||||
React.useState<ColorTheme>(defaultColorTheme);
|
||||
const [customColor, setCustomColor] = React.useState<string | undefined>();
|
||||
const { theme: modeTheme } = useTheme();
|
||||
|
||||
// Auth & DB Sync
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: dbTheme } = api.settings.getTheme.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
staleTime: Infinity, // Only fetch once on mount/auth
|
||||
});
|
||||
|
||||
const updateThemeMutation = api.settings.updateTheme.useMutation();
|
||||
|
||||
|
||||
|
||||
const setColorTheme = React.useCallback(
|
||||
(theme: ColorTheme, customColor?: string) => {
|
||||
const root = document.documentElement;
|
||||
const themes: ColorTheme[] = ["slate", "blue", "green", "rose", "orange"];
|
||||
|
||||
// Clear any existing custom styles
|
||||
const customProps = [
|
||||
"--primary",
|
||||
"--accent",
|
||||
"--ring",
|
||||
"--secondary",
|
||||
"--muted",
|
||||
];
|
||||
customProps.forEach((prop) => {
|
||||
if (root.style.getPropertyValue(prop)) {
|
||||
root.style.removeProperty(prop);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove all theme classes
|
||||
root.classList.remove(...themes);
|
||||
|
||||
if (theme === "custom" && customColor) {
|
||||
try {
|
||||
const colors = generateAccentColors(customColor);
|
||||
const themeColors = modeTheme === "dark" ? colors.dark : colors.light;
|
||||
|
||||
Object.entries(themeColors).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value);
|
||||
});
|
||||
|
||||
setColorThemeState("custom");
|
||||
setCustomColor(customColor);
|
||||
|
||||
// Persist custom theme locally
|
||||
const themeData = {
|
||||
color: customColor,
|
||||
timestamp: Date.now(),
|
||||
colors: colors,
|
||||
};
|
||||
localStorage.setItem("customThemeColor", JSON.stringify(themeData));
|
||||
localStorage.setItem("isCustomTheme", "true");
|
||||
localStorage.removeItem("color-theme");
|
||||
} catch (error) {
|
||||
console.error("Failed to apply custom theme:", error);
|
||||
// Fallback to default
|
||||
setColorThemeState(defaultColorTheme);
|
||||
setCustomColor(undefined);
|
||||
root.classList.add(defaultColorTheme);
|
||||
localStorage.setItem("color-theme", defaultColorTheme);
|
||||
return; // Don't sync failed theme
|
||||
}
|
||||
} else {
|
||||
// Apply preset color theme by setting the appropriate class
|
||||
setColorThemeState(theme);
|
||||
setCustomColor(undefined);
|
||||
root.classList.add(theme);
|
||||
|
||||
// Clear custom theme storage
|
||||
localStorage.removeItem("customThemeColor");
|
||||
localStorage.removeItem("isCustomTheme");
|
||||
|
||||
// Persist preset theme locally
|
||||
localStorage.setItem("color-theme", theme);
|
||||
}
|
||||
|
||||
// Sync to DB if authenticated
|
||||
// We check session inside the callback or pass it as dependency
|
||||
// But since this is a callback, we'll use the mutation directly if we can
|
||||
// However, we need to avoid infinite loops if the DB update triggers a re-render
|
||||
// The mutation is stable.
|
||||
},
|
||||
[modeTheme, defaultColorTheme],
|
||||
);
|
||||
|
||||
// Sync from DB when available
|
||||
React.useEffect(() => {
|
||||
if (dbTheme) {
|
||||
setColorTheme(dbTheme.colorTheme, dbTheme.customColor);
|
||||
}
|
||||
}, [dbTheme, setColorTheme]);
|
||||
|
||||
// Effect to trigger DB update when state changes (debounced or direct)
|
||||
// We do this separately to avoid putting mutation in the setColorTheme callback dependencies if possible
|
||||
// But actually, calling it in setColorTheme is better for direct user action.
|
||||
// The issue is `setColorTheme` is called by the `useEffect` that syncs FROM DB.
|
||||
// So we need to distinguish between "user set theme" and "synced from DB".
|
||||
// For now, we'll just let it be. If the DB sync calls setColorTheme, it will update state.
|
||||
// If we add a DB update call here, it might be redundant but harmless if the value is same.
|
||||
// BETTER APPROACH: Only call mutation when user interacts.
|
||||
// But `setColorTheme` is exposed to consumers.
|
||||
// Let's wrap the exposed `setColorTheme` to include the DB call.
|
||||
|
||||
const handleSetColorTheme = React.useCallback(
|
||||
(theme: ColorTheme, customColor?: string) => {
|
||||
setColorTheme(theme, customColor);
|
||||
|
||||
// Optimistic update is already done by setColorTheme (local state)
|
||||
// Now sync to DB
|
||||
if (session?.user) {
|
||||
updateThemeMutation.mutate({
|
||||
colorTheme: theme,
|
||||
customColor: theme === "custom" ? customColor : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setColorTheme, session?.user, updateThemeMutation]
|
||||
);
|
||||
|
||||
// Load saved theme on mount (Local Storage Fallback)
|
||||
React.useEffect(() => {
|
||||
// If we have DB data, that takes precedence (handled by other effect)
|
||||
// But initially or if offline/unauth, use local storage
|
||||
if (dbTheme) return;
|
||||
|
||||
try {
|
||||
const isCustom = localStorage.getItem("isCustomTheme") === "true";
|
||||
const savedThemeData = localStorage.getItem("customThemeColor");
|
||||
const savedColorTheme = localStorage.getItem("color-theme") as ColorTheme | null;
|
||||
|
||||
if (isCustom && savedThemeData) {
|
||||
const themeData = JSON.parse(savedThemeData) as {
|
||||
color: string;
|
||||
colors: Record<string, string>;
|
||||
};
|
||||
if (themeData?.color && themeData.colors) {
|
||||
setColorTheme("custom", themeData.color);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (savedColorTheme) {
|
||||
setColorTheme(savedColorTheme);
|
||||
} else {
|
||||
setColorTheme(defaultColorTheme);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load theme:", error);
|
||||
setColorTheme(defaultColorTheme);
|
||||
}
|
||||
}, [setColorTheme, defaultColorTheme, dbTheme]);
|
||||
|
||||
// Re-apply custom theme when mode changes
|
||||
React.useEffect(() => {
|
||||
if (colorTheme === "custom" && customColor) {
|
||||
setColorTheme("custom", customColor);
|
||||
}
|
||||
}, [modeTheme, colorTheme, customColor, setColorTheme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
colorTheme,
|
||||
setColorTheme: handleSetColorTheme, // Expose the wrapper
|
||||
customColor,
|
||||
}),
|
||||
[colorTheme, customColor, handleSetColorTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { api } from "~/trpc/react";
|
||||
import { authClient } from "~/lib/auth-client";
|
||||
|
||||
type Theme = "dark" | "light" | "system";
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
defaultTheme?: Theme;
|
||||
storageKey?: string;
|
||||
};
|
||||
|
||||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: "system",
|
||||
setTheme: () => null,
|
||||
};
|
||||
|
||||
const ThemeProviderContext = React.createContext<ThemeProviderState>(initialState);
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = "system",
|
||||
storageKey = "theme",
|
||||
...props
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = React.useState<Theme>(defaultTheme);
|
||||
|
||||
// Auth & DB Sync
|
||||
const { data: session } = authClient.useSession();
|
||||
const { data: dbTheme } = api.settings.getTheme.useQuery(undefined, {
|
||||
enabled: !!session?.user,
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const updateThemeMutation = api.settings.updateTheme.useMutation();
|
||||
|
||||
// Sync from DB
|
||||
React.useEffect(() => {
|
||||
if (dbTheme?.theme) {
|
||||
setTheme(dbTheme.theme);
|
||||
}
|
||||
}, [dbTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedTheme = localStorage.getItem(storageKey) as Theme | null;
|
||||
if (savedTheme && !dbTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, [storageKey, dbTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const systemTheme = media.matches ? "dark" : "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
|
||||
const listener = (e: MediaQueryListEvent) => {
|
||||
const newTheme = e.matches ? "dark" : "light";
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(newTheme);
|
||||
};
|
||||
|
||||
media.addEventListener("change", listener);
|
||||
return () => media.removeEventListener("change", listener);
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (newTheme: Theme) => {
|
||||
localStorage.setItem(storageKey, newTheme);
|
||||
setTheme(newTheme);
|
||||
|
||||
if (session?.user) {
|
||||
updateThemeMutation.mutate({ theme: newTheme });
|
||||
}
|
||||
},
|
||||
}),
|
||||
[theme, storageKey, session?.user, updateThemeMutation]
|
||||
);
|
||||
|
||||
return (
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</ThemeProviderContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = React.useContext(ThemeProviderContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error("useTheme must be used within a ThemeProvider");
|
||||
|
||||
return context;
|
||||
};
|
||||
@@ -1,148 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Check, Palette } from "lucide-react";
|
||||
import { cn } from "~/lib/utils";
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "~/components/ui/popover";
|
||||
import { Label } from "~/components/ui/label";
|
||||
import { useColorTheme } from "~/components/providers/color-theme-provider";
|
||||
|
||||
const presetColors = [
|
||||
{ name: "Slate", hex: "#64748b" },
|
||||
{ name: "Blue", hex: "#3b82f6" },
|
||||
{ name: "Green", hex: "#22c55e" },
|
||||
{ name: "Rose", hex: "#be123c" },
|
||||
{ name: "Orange", hex: "#ea580c" },
|
||||
{ name: "Purple", hex: "#8b5cf6" },
|
||||
{ name: "Teal", hex: "#14b8a6" },
|
||||
{ name: "Pink", hex: "#ec4899" },
|
||||
];
|
||||
|
||||
export function AccentColorSwitcher() {
|
||||
const {
|
||||
colorTheme,
|
||||
setColorTheme,
|
||||
customColor: savedCustomColor,
|
||||
} = useColorTheme();
|
||||
const [customColorInput, setCustomColorInput] = React.useState("");
|
||||
const [isCustom, setIsCustom] = React.useState(colorTheme === "custom");
|
||||
|
||||
React.useEffect(() => {
|
||||
setIsCustom(colorTheme === "custom");
|
||||
if (savedCustomColor) {
|
||||
setCustomColorInput(savedCustomColor);
|
||||
}
|
||||
}, [colorTheme, savedCustomColor]);
|
||||
|
||||
const handleColorChange = (color: { name: string; hex: string }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
||||
setColorTheme(color.name.toLowerCase() as any);
|
||||
};
|
||||
|
||||
const handleCustomColorSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (/^#[0-9A-F]{6}$/i.test(customColorInput)) {
|
||||
setColorTheme("custom", customColorInput);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setColorTheme("slate");
|
||||
setCustomColorInput("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label>Accent Color</Label>
|
||||
<p className="text-muted-foreground text-xs leading-snug">
|
||||
Choose an accent color for your theme or create a custom one.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{presetColors.map((color) => (
|
||||
<Button
|
||||
key={color.name}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-10 w-10 rounded-lg border-2",
|
||||
colorTheme === color.name.toLowerCase() &&
|
||||
!isCustom &&
|
||||
"border-primary ring-primary ring-2 ring-offset-2",
|
||||
isCustom && "opacity-50",
|
||||
)}
|
||||
onClick={() => handleColorChange(color)}
|
||||
style={{ backgroundColor: color.hex }}
|
||||
title={color.name}
|
||||
>
|
||||
{colorTheme === color.name.toLowerCase() && !isCustom && (
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
<Palette className="mr-2 h-4 w-4" />
|
||||
Custom Color
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<form onSubmit={handleCustomColorSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom-color">Hex Color</Label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
id="custom-color"
|
||||
type="text"
|
||||
placeholder="#FF6B6B"
|
||||
value={customColorInput}
|
||||
onChange={(e) => setCustomColorInput(e.target.value)}
|
||||
className="flex-1 rounded-md border px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
value={customColorInput}
|
||||
onChange={(e) => setCustomColorInput(e.target.value)}
|
||||
className="h-10 w-12 cursor-pointer rounded-md border"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" className="w-full">
|
||||
Apply Custom Color
|
||||
</Button>
|
||||
</form>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{isCustom && (
|
||||
<div className="bg-muted flex items-center gap-2 rounded-lg p-3">
|
||||
<div
|
||||
className="h-6 w-6 rounded border"
|
||||
style={{ backgroundColor: savedCustomColor }}
|
||||
/>
|
||||
<span className="text-sm font-medium">Custom Theme Active</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReset}
|
||||
className="ml-auto"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "~/components/providers/theme-provider";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={toggleMode}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
import { useTheme } from "~/components/providers/theme-provider";
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
const { theme = "system" } = useTheme();
|
||||
|
||||
return (
|
||||
<Sonner
|
||||
theme={theme as ToasterProps["theme"]}
|
||||
theme="system"
|
||||
className="toaster group"
|
||||
position="bottom-right"
|
||||
closeButton
|
||||
|
||||
Reference in New Issue
Block a user