mirror of
https://github.com/soconnor0919/beenvoice.git
synced 2026-02-05 08:16:31 -05:00
feat: Implement dynamic accent color selection and refactor appearance settings
This commit is contained in:
156
src/components/providers/color-theme-provider.tsx
Normal file
156
src/components/providers/color-theme-provider.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { useTheme } from "./theme-provider";
|
||||
import { generateAccentColors } from "~/lib/color-utils";
|
||||
|
||||
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();
|
||||
|
||||
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
|
||||
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);
|
||||
}
|
||||
} 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
|
||||
localStorage.setItem("color-theme", theme);
|
||||
}
|
||||
},
|
||||
[modeTheme, defaultColorTheme],
|
||||
);
|
||||
|
||||
// Load saved theme on mount
|
||||
React.useEffect(() => {
|
||||
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);
|
||||
if (themeData && 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]);
|
||||
|
||||
// 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,
|
||||
customColor,
|
||||
}),
|
||||
[colorTheme, customColor, setColorTheme],
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ColorThemeContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { type ThemeProviderProps } from "next-themes/dist/types";
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
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);
|
||||
|
||||
React.useEffect(() => {
|
||||
const savedTheme = localStorage.getItem(storageKey) as Theme | null;
|
||||
if (savedTheme) {
|
||||
setTheme(savedTheme);
|
||||
}
|
||||
}, [storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
root.classList.remove("light", "dark");
|
||||
|
||||
if (theme === "system") {
|
||||
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
||||
.matches
|
||||
? "dark"
|
||||
: "light";
|
||||
|
||||
root.classList.add(systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
},
|
||||
}),
|
||||
[theme, storageKey]
|
||||
);
|
||||
|
||||
return (
|
||||
<NextThemesProvider
|
||||
{...props}
|
||||
themes={["light", "dark", "theme-ocean", "theme-sunset", "theme-forest"]}
|
||||
>
|
||||
<ThemeProviderContext.Provider {...props} value={value}>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
</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;
|
||||
};
|
||||
|
||||
147
src/components/theme/accent-color-switcher.tsx
Normal file
147
src/components/theme/accent-color-switcher.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"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 }) => {
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -2,39 +2,23 @@
|
||||
|
||||
import * as React from "react";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useTheme } from "~/components/providers/theme-provider";
|
||||
|
||||
import { Button } from "~/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "~/components/ui/dropdown-menu";
|
||||
|
||||
export function ThemeSwitcher() {
|
||||
const { setTheme } = useTheme();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
const toggleMode = () => {
|
||||
const newMode = theme === "dark" ? "light" : "dark";
|
||||
setTheme(newMode);
|
||||
};
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>
|
||||
Light
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
||||
Dark
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>
|
||||
System
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
61
src/components/ui/tooltip.tsx
Normal file
61
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
|
||||
|
||||
import { cn } from "~/lib/utils"
|
||||
|
||||
function TooltipProvider({
|
||||
delayDuration = 0,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
|
||||
return (
|
||||
<TooltipPrimitive.Provider
|
||||
data-slot="tooltip-provider"
|
||||
delayDuration={delayDuration}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Tooltip({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
Reference in New Issue
Block a user