feat: Implement dynamic accent color selection and refactor appearance settings

This commit is contained in:
2025-11-29 00:49:24 -05:00
parent 10e1ca8396
commit c88e5d9d82
26 changed files with 1319 additions and 1235 deletions

View File

@@ -0,0 +1,13 @@
"use client";
import { ModeSwitcher } from "./mode-switcher";
import { ThemeSelector } from "./theme-selector";
export function AppearanceSettings() {
return (
<div className="space-y-6">
<ModeSwitcher />
<ThemeSelector />
</div>
);
}

View File

@@ -1,54 +0,0 @@
"use client";
import { useTheme } from "next-themes";
import { Check, Palette } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Button } from "~/components/ui/button";
export function ColorThemeSelector() {
const { theme, setTheme } = useTheme();
const themes = [
{ name: "theme-ocean", label: "Ocean" },
{ name: "theme-sunset", label: "Sunset" },
{ name: "theme-forest", label: "Forest" },
];
const currentTheme = themes.find((t) => t.name === theme)?.label ?? "Ocean";
return (
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<label className="font-medium">Color Theme</label>
<p className="text-muted-foreground text-xs leading-snug">
Select a color theme for the application.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-40 justify-between">
<span>{currentTheme}</span>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{themes.map((t) => (
<DropdownMenuItem
key={t.name}
className="flex justify-between"
onClick={() => setTheme(t.name)}
>
<span>{t.label}</span>
{theme === t.name && <Check className="h-4 w-4" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useTheme } from "next-themes";
import { useTheme } from "~/components/providers/theme-provider";
import { Sun, Moon, Laptop } from "lucide-react";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs";
@@ -9,13 +9,19 @@ export function ModeSwitcher() {
return (
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<div className="space-y-1.5">
<label className="font-medium">Appearance</label>
<p className="text-muted-foreground text-xs leading-snug">
Select a light or dark mode, or sync with your system.
{theme === "system"
? "Follows system preference"
: `Currently in ${theme} mode`}
</p>
</div>
<Tabs defaultValue={theme} onValueChange={setTheme} className="w-auto">
<Tabs
value={theme}
onValueChange={(value) => setTheme(value as "light" | "dark" | "system")}
className="w-auto"
>
<TabsList>
<TabsTrigger value="light">
<Sun className="h-4 w-4" />

View File

@@ -62,8 +62,7 @@ import { Textarea } from "~/components/ui/textarea";
import { api } from "~/trpc/react";
import { Switch } from "~/components/ui/switch";
import { Slider } from "~/components/ui/slider";
import { ModeSwitcher } from "./mode-switcher";
import { ColorThemeSelector } from "./color-theme-selector";
import { AppearanceSettings } from "./appearance-settings";
import { useAnimationPreferences } from "~/components/providers/animation-preferences-provider";
export function SettingsContent() {
@@ -630,8 +629,7 @@ export function SettingsContent() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ModeSwitcher />
<ColorThemeSelector />
<AppearanceSettings />
</CardContent>
</Card>

View File

@@ -1,55 +1,61 @@
"use client";
import { useTheme } from "next-themes";
import { Check, Palette } from "lucide-react";
import * as React from "react";
import { Check } from "lucide-react";
import { cn } from "~/lib/utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { Button } from "~/components/ui/button";
import { useColorTheme } from "~/components/providers/color-theme-provider";
const themes = [
{ name: "slate", hex: "#64748b" },
{ name: "blue", hex: "#3b82f6" },
{ name: "green", hex: "#22c55e" },
{ name: "rose", hex: "#be123c" },
{ name: "orange", hex: "#ea580c" },
];
export function ThemeSelector() {
const { theme, setTheme } = useTheme();
const themes = [
{ name: "light", label: "Light" },
{ name: "dark", label: "Dark" },
{ name: "theme-sunset", label: "Sunset" },
{ name: "theme-forest", label: "Forest" },
];
const { colorTheme, setColorTheme } = useColorTheme();
return (
<div className="flex items-center justify-between">
<div className="space-y-1.5">
<label className="font-medium">Theme</label>
<p className="text-muted-foreground text-xs leading-snug">
Select a theme for the application.
</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="w-40 justify-between">
<span>
{themes.find((t) => t.name === theme)?.label ?? "Light"}
</span>
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="space-y-1.5">
<label className="font-medium">Theme</label>
<p className="text-muted-foreground text-xs leading-snug">
Select a theme for the application.
</p>
<div className="flex items-center gap-2 pt-2">
<TooltipProvider>
{themes.map((t) => (
<DropdownMenuItem
key={t.name}
className="flex justify-between"
onClick={() => setTheme(t.name)}
>
<span>{t.label}</span>
{theme === t.name && <Check className="h-4 w-4" />}
</DropdownMenuItem>
<Tooltip key={t.name}>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className={cn(
"h-8 w-8 rounded-full border-2",
colorTheme === t.name && "border-primary",
)}
onClick={() => setColorTheme(t.name as any)}
style={{ backgroundColor: t.hex }}
>
{colorTheme === t.name && (
<Check className="h-4 w-4 text-white" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p className="capitalize">{t.name}</p>
</TooltipContent>
</Tooltip>
))}
</DropdownMenuContent>
</DropdownMenu>
</TooltipProvider>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import { Toaster } from "~/components/ui/sonner";
import { AnimationPreferencesProvider } from "~/components/providers/animation-preferences-provider";
import { ThemeProvider } from "~/components/providers/theme-provider";
import { ColorThemeProvider } from "~/components/providers/color-theme-provider";
export const metadata: Metadata = {
title: "beenvoice - Invoicing Made Simple",
@@ -46,27 +47,106 @@ export default function RootLayout({
className={`${geistSans.variable} ${geistMono.variable} ${instrumentSerif.variable}`}
>
<head>
{/* Inline early animation preference script to avoid FOUC */}
{/* Inline early theme and animation preference script to avoid FOUC */}
<script
dangerouslySetInnerHTML={{
__html: `(function(){try{var STORAGE_KEY='bv.animation.prefs';var raw=localStorage.getItem(STORAGE_KEY);var prefersReduced=false;var speed=1;if(raw){try{var parsed=JSON.parse(raw);if(typeof parsed.prefersReducedMotion==='boolean'){prefersReduced=parsed.prefersReducedMotion;}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}if(typeof parsed.animationSpeedMultiplier==='number'){speed=parsed.animationSpeedMultiplier;if(isNaN(speed)||speed<0.25||speed>4)speed=1;}}catch(e){}}else{prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;}var root=document.documentElement;if(prefersReduced)root.classList.add('user-reduce-motion');function apply(fast,normal,slow){root.style.setProperty('--animation-speed-fast',fast+'s');root.style.setProperty('--animation-speed-normal',normal+'s');root.style.setProperty('--animation-speed-slow',slow+'s');}if(prefersReduced){apply(0.01,0.01,0.01);}else{var fast=(0.15/speed).toFixed(4);var normal=(0.30/speed).toFixed(4);var slow=(0.50/speed).toFixed(4);apply(fast,normal,slow);}}catch(_e){}})();`,
__html: `(function(){
try {
var root = document.documentElement;
// Mode theme persistence (light/dark/system)
var modeTheme = localStorage.getItem('theme');
var systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
root.classList.remove('light', 'dark');
if (modeTheme === 'dark' || modeTheme === 'light') {
root.classList.add(modeTheme);
} else {
// Default to system if no preference or 'system'
root.classList.add(systemTheme);
}
// Color theme persistence (custom accent colors)
var customColor = localStorage.getItem('customThemeColor');
var isCustom = localStorage.getItem('isCustomTheme') === 'true';
if (isCustom && customColor) {
try {
var themeData = JSON.parse(customColor);
if (themeData && themeData.colors && themeData.colors.light) {
// Apply saved colors directly
for (var key in themeData.colors.light) {
if (themeData.colors.light.hasOwnProperty(key)) {
root.style.setProperty(key, themeData.colors.light[key]);
}
}
}
} catch (e) {
// Fallback logic omitted for brevity, relying on provider for full recovery
}
} else {
// Apply preset color theme
var colorTheme = localStorage.getItem('color-theme');
if (colorTheme) {
root.classList.add(colorTheme);
} else {
root.classList.add('slate'); // Default
}
}
// Animation preferences script (existing)
var STORAGE_KEY='bv.animation.prefs';
var raw=localStorage.getItem(STORAGE_KEY);
var prefersReduced=false;
var speed=1;
if(raw){
try{
var parsed=JSON.parse(raw);
if(typeof parsed.prefersReducedMotion==='boolean'){
prefersReduced=parsed.prefersReducedMotion;
}else{
prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
if(typeof parsed.animationSpeedMultiplier==='number'){
speed=parsed.animationSpeedMultiplier;
if(isNaN(speed)||speed<0.25||speed>4)speed=1;
}
}catch(e){}
}else{
prefersReduced=window.matchMedia&&window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
if(prefersReduced)root.classList.add('user-reduce-motion');
function apply(fast,normal,slow){
root.style.setProperty('--animation-speed-fast',fast+'s');
root.style.setProperty('--animation-speed-normal',normal+'s');
root.style.setProperty('--animation-speed-slow',slow+'s');
}
if(prefersReduced){
apply(0.01,0.01,0.01);
}else{
var fast=(0.15/speed).toFixed(4);
var normal=(0.30/speed).toFixed(4);
var slow=(0.50/speed).toFixed(4);
apply(fast,normal,slow);
}
} catch(_e) {}
})();`,
}}
/>
</head>
<Analytics />
<body className="bg-background text-foreground relative min-h-screen overflow-x-hidden font-sans antialiased">
<ThemeProvider
attribute="class"
defaultTheme="theme-ocean"
enableSystem
disableTransitionOnChange
>
<TRPCReactProvider>
<AnimationPreferencesProvider>
{children}
</AnimationPreferencesProvider>
<Toaster />
</TRPCReactProvider>
<ThemeProvider>
<ColorThemeProvider>
<TRPCReactProvider>
<AnimationPreferencesProvider>
{children}
</AnimationPreferencesProvider>
<Toaster />
</TRPCReactProvider>
</ColorThemeProvider>
</ThemeProvider>
</body>
</html>

View 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>
);
}

View File

@@ -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;
};

View 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>
);
}

View File

@@ -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>
);
}

View 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 }

View File

@@ -12,7 +12,10 @@ export const env = createEnv({
? z.string()
: z.string().optional(),
DATABASE_URL: z.string().url(),
RESEND_API_KEY: z.string().min(1),
RESEND_API_KEY:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
RESEND_DOMAIN: z.string().optional(),
NODE_ENV: z
.enum(["development", "test", "production"])

274
src/lib/color-utils.ts Normal file
View File

@@ -0,0 +1,274 @@
type Oklch = {
l: number;
c: number;
h: number;
};
/**
* Converts a hexadecimal color string to an Oklch color object.
*
* @param {string} hex - The hexadecimal color string (e.g., "#RRGGBB", "RRGGBB", "#RGB", "RGB").
* @returns {Oklch} The Oklch color object.
* @throws {Error} If the hex color format is invalid.
*/
export function hexToOklch(hex: string): Oklch {
const rgb = hexToRgb(hex);
const linear_rgb = rgb.map(srgbToLinearRgb) as [number, number, number];
const xyz = linearRgbToXyz(linear_rgb);
const oklab = xyzToOklab(xyz);
const oklch = oklabToOklch(oklab);
return {
l: oklch[0] || 0,
c: oklch[1] || 0,
h: oklch[2] || 0,
};
}
export function generateAccentColors(hex: string) {
const base = hexToOklch(hex);
const light = {
"--background": `oklch(0.99 ${base.c * 0.05} ${base.h})`,
"--foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--card": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--card-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--popover": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--popover-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--primary": `oklch(0.6 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--secondary-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--muted": `oklch(0.95 ${base.c * 0.2} ${base.h})`,
"--muted-foreground": `oklch(0.5 ${base.c * 0.4} ${base.h})`,
"--accent": `oklch(0.98 ${base.c * 0.6} ${base.h})`,
"--accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--destructive": "oklch(0.58 0.24 28)",
"--destructive-foreground": "oklch(0.98 0.01 230)",
"--success": "oklch(0.55 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.65 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--input": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--ring": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.98 ${base.c * 0.05} ${base.h})`,
"--sidebar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-primary": `oklch(0.6 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.9 ${base.c * 0.4} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.1 ${base.c * 0.8} ${base.h})`,
"--sidebar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
"--sidebar-ring": `oklch(0.6 ${base.c} ${base.h})`,
"--navbar": `oklch(1 ${base.c * 0.02} ${base.h})`,
"--navbar-foreground": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--navbar-border": `oklch(0.9 ${base.c * 0.3} ${base.h})`,
};
const dark = {
"--background": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--card": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--card-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--popover": `oklch(0.17 ${base.c * 0.2} ${base.h})`,
"--popover-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--primary": `oklch(0.7 ${base.c} ${base.h})`,
"--primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--secondary": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--secondary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--muted": `oklch(0.25 ${base.c * 0.3} ${base.h})`,
"--muted-foreground": `oklch(0.7 ${base.c * 0.2} ${base.h})`,
"--accent": `oklch(0.3 ${base.c * 0.5} ${base.h})`,
"--accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--destructive": "oklch(0.7 0.19 22)",
"--destructive-foreground": "oklch(0.2 0.03 230)",
"--success": "oklch(0.6 0.15 142)",
"--success-foreground": "oklch(0.98 0.01 230)",
"--warning": "oklch(0.7 0.15 38)",
"--warning-foreground": "oklch(0.2 0.03 230)",
"--border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--input": `oklch(0.35 ${base.c * 0.4} ${base.h})`,
"--ring": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar": `oklch(0.1 ${base.c * 0.1} ${base.h})`,
"--sidebar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-primary": `oklch(0.7 ${base.c} ${base.h})`,
"--sidebar-primary-foreground": `oklch(${base.l > 0.6 ? 0.1 : 0.98} ${
base.c * 0.2
} ${base.h})`,
"--sidebar-accent": `oklch(0.3 ${base.c * 0.7} ${base.h})`,
"--sidebar-accent-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--sidebar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
"--sidebar-ring": `oklch(0.7 ${base.c} ${base.h})`,
"--navbar": `oklch(0.15 ${base.c * 0.15} ${base.h})`,
"--navbar-foreground": `oklch(0.95 ${base.c * 0.05} ${base.h})`,
"--navbar-border": `oklch(0.28 ${base.c * 0.4} ${base.h})`,
};
return { light, dark };
}
/**
* Converts a hexadecimal color string to an array of R, G, B components (0-255).
* Supports "#RRGGBB", "RRGGBB", "#RGB", "RGB" formats.
* @param {string} hex - The hexadecimal color string.
* @returns {number[]} An array [r, g, b].
* @throws {Error} If the hex color format is invalid.
*/
function hexToRgb(hex: string): [number, number, number] {
let r = 0,
g = 0,
b = 0;
// Remove '#' if present
if (hex.startsWith("#")) {
hex = hex.slice(1);
}
// Handle 3-digit hex (e.g., "F0C" -> "FF00CC")
if (hex.length === 3) {
const chars = hex.split("");
if (
chars.length === 3 &&
chars.every((char) => /^[0-9A-Fa-f]$/.test(char))
) {
r = parseInt(chars[0]! + chars[0]!, 16);
g = parseInt(chars[1]! + chars[1]!, 16);
b = parseInt(chars[2]! + chars[2]!, 16);
} else {
throw new Error("Invalid 3-digit hex color format.");
}
}
// Handle 6-digit hex (e.g., "FF00CC")
else if (hex.length === 6) {
const rStr = hex.substring(0, 2);
const gStr = hex.substring(2, 4);
const bStr = hex.substring(4, 6);
if (
/^[0-9A-Fa-f]{2}$/.test(rStr) &&
/^[0-9A-Fa-f]{2}$/.test(gStr) &&
/^[0-9A-Fa-f]{2}$/.test(bStr)
) {
r = parseInt(rStr, 16);
g = parseInt(gStr, 16);
b = parseInt(bStr, 16);
} else {
throw new Error("Invalid 6-digit hex color format.");
}
} else {
throw new Error("Invalid hex color format. Use #RRGGBB or #RGB.");
}
return [r, g, b];
}
/**
* Converts an sRGB component (0-255) to a linear sRGB component (0-1).
* @param {number} c - The sRGB component value (0-255).
* @returns {number} The linear sRGB component value (0-1).
*/
function srgbToLinearRgb(c: number) {
c /= 255; // Normalize to [0, 1]
// Apply the sRGB gamma correction formula.
if (c <= 0.04045) {
return c / 12.92;
} else {
return Math.pow((c + 0.055) / 1.055, 2.4);
}
}
/**
* Multiplies a 3x3 matrix by a 3-element vector.
* @param {number[][]} matrix - The 3x3 matrix.
* @param {number[]} vector - The 3-element vector.
* @returns {number[]} The resulting 3-element vector.
*/
function multiplyMatrix(
matrix: number[][],
vector: number[],
): [number, number, number] {
const result = new Array(matrix.length).fill(0);
for (let i = 0; i < matrix.length; i++) {
for (let j = 0; j < vector.length; j++) {
result[i]! += matrix[i]![j]! * vector[j]!;
}
}
return [result[0]!, result[1]!, result[2]!];
}
/**
* Converts linear sRGB values to CIE XYZ values (D65 white point).
* @param {number[]} rgb_linear - An array [r, g, b] of linear sRGB components (0-1).
* @returns {number[]} An array [X, Y, Z] of CIE XYZ components.
*/
function linearRgbToXyz(
rgb_linear: [number, number, number],
): [number, number, number] {
// Standard sRGB to XYZ D65 conversion matrix.
const M_srgb_to_xyz = [
[0.4123908, 0.35758434, 0.18048079],
[0.21263901, 0.71516868, 0.07219232],
[0.01933082, 0.11919478, 0.95053215],
];
return multiplyMatrix(M_srgb_to_xyz, rgb_linear);
}
/**
* Converts CIE XYZ values to Oklab values.
* @param {number[]} xyz - An array [X, Y, Z] of CIE XYZ components.
* @returns {number[]} An array [L, a, b] of Oklab components.
*/
function xyzToOklab(xyz: [number, number, number]): [number, number, number] {
// Convert XYZ to LMS (linear cone responses).
const M_xyz_to_lms = [
[0.81890226, 0.03298366, 0.05591174],
[0.36186742, 0.638518, 0.00083942],
[0, 0, 0.82521],
];
const lms = multiplyMatrix(M_xyz_to_lms, xyz);
// Apply cube root non-linearity to LMS values.
const lms_prime = lms.map((val) => Math.cbrt(val)) as [
number,
number,
number,
];
// Convert LMS' to Oklab.
const M_lms_prime_to_oklab = [
[0.2104542553, 0.793617785, -0.0040720468],
[1.9779984951, -2.428592205, 0.4505937099],
[0.0259040371, 0.7827717662, -0.808675766],
];
return multiplyMatrix(M_lms_prime_to_oklab, lms_prime);
}
/**
* Converts Oklab values to Oklch values.
* @param {number[]} oklab - An array [L, a, b] of Oklab components (L in 0-1).
* @returns {number[]} An array [L, C, h] of Oklch components (L in 0-100, h in degrees).
*/
function oklabToOklch(oklab: number[]): [number, number, number] {
const L = oklab[0] ?? 0; // Oklab L is 0-1
const a = oklab[1] ?? 0;
const b = oklab[2] ?? 0;
const C = Math.sqrt(a * a + b * b); // Chroma
let h = Math.atan2(b, a) * (180 / Math.PI); // Hue in degrees
// Normalize hue to [0, 360)
if (h < 0) {
h += 360;
}
// Oklch L is typically scaled to 0-100.
return [L, C, h];
}

File diff suppressed because it is too large Load Diff