diff --git a/src/app/(general)/_components/landing-page/navbar/index.tsx b/src/app/(general)/_components/landing-page/navbar/index.tsx index 9f0e8fb2..e87dfde1 100644 --- a/src/app/(general)/_components/landing-page/navbar/index.tsx +++ b/src/app/(general)/_components/landing-page/navbar/index.tsx @@ -33,7 +33,9 @@ export const Navbar = () => { - +
+ +
diff --git a/src/app/(general)/_components/sidebar/user.tsx b/src/app/(general)/_components/sidebar/user.tsx index 867f079e..c0c16bab 100644 --- a/src/app/(general)/_components/sidebar/user.tsx +++ b/src/app/(general)/_components/sidebar/user.tsx @@ -1,6 +1,6 @@ "use client"; -import { ChevronRight, LogOut, Moon, Sun, User } from "lucide-react"; +import { ChevronRight, LogOut, Moon, Sun, User, Palette } from "lucide-react"; import Link from "next/link"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -19,7 +19,9 @@ import { SidebarMenuItem, useSidebar, } from "@/components/ui/sidebar"; +import { ThemeSwitcher } from "@/components/theme"; import { useTheme } from "next-themes"; +import { useTheme as useCustomTheme } from "@/contexts/theme"; import { signOut } from "next-auth/react"; export function NavUser({ @@ -33,6 +35,13 @@ export function NavUser({ }) { const { isMobile } = useSidebar(); const { theme, setTheme } = useTheme(); + const { currentMode, toggleMode } = useCustomTheme(); + + const handleThemeToggle = (e: React.MouseEvent) => { + e.preventDefault(); + setTheme(theme === "light" ? "dark" : "light"); + toggleMode(); + }; return ( @@ -84,20 +93,25 @@ export function NavUser({ - { - e.preventDefault(); - setTheme(theme === "light" ? "dark" : "light"); - }} - > - {theme === "light" ? ( + + {currentMode === "light" ? ( ) : ( )} - {theme === "light" ? "Light mode" : "Dark mode"} + {currentMode === "light" ? "Light mode" : "Dark mode"} + + e.preventDefault()} + onClick={(e) => e.stopPropagation()} + > + + Theme + + + signOut()}> Sign Out diff --git a/src/app/_components/navbar/color-mode-toggle.tsx b/src/app/_components/navbar/color-mode-toggle.tsx index fd31c90a..ac02245c 100644 --- a/src/app/_components/navbar/color-mode-toggle.tsx +++ b/src/app/_components/navbar/color-mode-toggle.tsx @@ -1,22 +1,30 @@ "use client"; import { useTheme } from "next-themes"; +import { useTheme as useCustomTheme } from "@/contexts/theme"; import { Button } from "@/components/ui/button"; import { Moon, Sun } from "lucide-react"; export const ColorModeToggle = () => { const { theme, setTheme } = useTheme(); + const { currentMode, toggleMode } = useCustomTheme(); + + const handleToggle = () => { + // Toggle both next-themes and our custom theme + setTheme(theme === "light" ? "dark" : "light"); + toggleMode(); + }; return ( <> + + )} + + + + + Appearance + + + +
+ {/* Controls */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + + + + + + + Switch to {currentMode === "light" ? "dark" : "light"} mode + + + + + + + + Random theme + + + + + + + Import custom theme + + +
+ + {/* Theme Grid */} + +
+ {/* Built-in Themes */} + {builtInThemes.length > 0 && ( +
+

+ Built-in Themes +

+
+ {builtInThemes + .filter((theme) => + theme.name + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ) + .map((theme) => ( + { + applyPreset(theme); + setIsOpen(false); + }} + previewColors={getThemePreview(theme)} + /> + ))} +
+
+ )} + + {/* Custom Themes */} + {customThemes.length > 0 && ( +
+

+ Custom Themes +

+
+ {customThemes + .filter((theme) => + theme.name + .toLowerCase() + .includes(searchQuery.toLowerCase()), + ) + .map((theme) => ( + { + applyPreset(theme); + setIsOpen(false); + }} + previewColors={getThemePreview(theme)} + /> + ))} +
+
+ )} + + {isLoading && ( +
+
+ Loading themes... +
+
+ )} + + {!isLoading && filteredThemes.length === 0 && searchQuery && ( +
+
+ No themes found matching "{searchQuery}" +
+
+ )} +
+
+ + {/* Actions */} +
+ +
+ {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""}{" "} + available +
+
+
+ + +
+ + ); +} + +interface ThemeCardProps { + theme: ThemePreset; + onSelect: () => void; + previewColors: string[]; +} + +function ThemeCard({ theme, onSelect, previewColors }: ThemeCardProps) { + return ( + + ); +} + +interface ImportThemeDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +function ImportThemeDialog({ open, onOpenChange }: ImportThemeDialogProps) { + const { importTheme, isImporting } = useThemeManagement(); + const [url, setUrl] = useState(""); + const [error, setError] = useState(""); + + const handleImport = async () => { + if (!url.trim()) { + setError("Please enter a theme URL"); + return; + } + + try { + setError(""); + importTheme(url.trim()); + setUrl(""); + onOpenChange(false); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to import theme"); + } + }; + + return ( + + + + + + Import Custom Theme + + + +
+
+ + { + setUrl(e.target.value); + setError(""); + }} + className="mt-1" + /> + {error &&

{error}

} +
+ +
+

Supported formats:

+
    +
  • tweakcn.com theme URLs
  • +
  • Direct JSON theme files
  • +
+
+ +
+ + +
+
+
+
+ ); +} diff --git a/src/contexts/theme/apply-theme.ts b/src/contexts/theme/apply-theme.ts new file mode 100644 index 00000000..6db6fa23 --- /dev/null +++ b/src/contexts/theme/apply-theme.ts @@ -0,0 +1,40 @@ +import type { ThemeState } from "./types"; + +/** + * Applies theme CSS variables to a DOM element + * @param themeState - The theme state containing CSS variables + * @param element - The DOM element to apply the theme to (usually document.documentElement) + */ +export function applyThemeToElement( + themeState: ThemeState, + element: HTMLElement = document.documentElement, +): void { + // Apply global theme variables + Object.entries(themeState.cssVars.theme).forEach(([key, value]) => { + element.style.setProperty(`--${key}`, value); + }); + + // Apply mode-specific variables + const modeVars = themeState.cssVars[themeState.currentMode]; + Object.entries(modeVars).forEach(([key, value]) => { + element.style.setProperty(`--${key}`, value); + }); + + // Set data attribute for theme mode + element.setAttribute("data-theme", themeState.currentMode); + + // Set classes for compatibility + element.classList.toggle("light", themeState.currentMode === "light"); + element.classList.toggle("dark", themeState.currentMode === "dark"); +} + +/** + * Gets the preferred color scheme from user's system + */ +export function getPreferredColorScheme(): "light" | "dark" { + if (typeof window === "undefined") return "light"; + + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} diff --git a/src/contexts/theme/default-theme.ts b/src/contexts/theme/default-theme.ts new file mode 100644 index 00000000..db74b8f3 --- /dev/null +++ b/src/contexts/theme/default-theme.ts @@ -0,0 +1,104 @@ +import type { ThemeState } from "./types"; + +export const DEFAULT_THEME: ThemeState = { + currentMode: "light", + cssVars: { + // Global theme tokens (apply to both modes) + theme: { + // Typography + "font-sans": "Space Grotesk, serif", + "font-serif": "ui-serif, Georgia, Cambria, Times New Roman, Times, serif", + "font-mono": "JetBrains Mono, monospace", + + // Border radius + radius: "0.5rem", + + // Shadows + "shadow-2xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)", + "shadow-xs": "0 1px 3px 0px hsl(0 0% 0% / 0.05)", + "shadow-sm": + "0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1)", + shadow: + "0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 1px 2px -1px hsl(0 0% 0% / 0.1)", + "shadow-md": + "0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 2px 4px -1px hsl(0 0% 0% / 0.1)", + "shadow-lg": + "0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 4px 6px -1px hsl(0 0% 0% / 0.1)", + "shadow-xl": + "0 1px 3px 0px hsl(0 0% 0% / 0.1), 0 8px 10px -1px hsl(0 0% 0% / 0.1)", + "shadow-2xl": "0 1px 3px 0px hsl(0 0% 0% / 0.25)", + }, + + // Light mode colors + light: { + background: "oklch(0.9551 0 0)", + foreground: "oklch(0.3211 0 0)", + card: "oklch(0.9702 0 0)", + "card-foreground": "oklch(0.3211 0 0)", + popover: "oklch(0.9702 0 0)", + "popover-foreground": "oklch(0.3211 0 0)", + primary: "#4299e1", + "primary-foreground": "#ffffff", + secondary: "oklch(0.9067 0 0)", + "secondary-foreground": "oklch(0.3211 0 0)", + muted: "oklch(0.9067 0 0)", + "muted-foreground": "oklch(0.5103 0 0)", + accent: "oklch(0.94 0 0)", + "accent-foreground": "oklch(0 0 0)", + destructive: "oklch(0.5594 0.19 25.8625)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.8576 0 0)", + input: "oklch(0.9067 0 0)", + ring: "#4299e1", + "chart-1": "oklch(0.4891 0 0)", + "chart-2": "oklch(0.4863 0.0361 196.0278)", + "chart-3": "oklch(0.6534 0 0)", + "chart-4": "oklch(0.7316 0 0)", + "chart-5": "oklch(0.8078 0 0)", + sidebar: "oklch(0.937 0 0)", + "sidebar-foreground": "oklch(0.3211 0 0)", + "sidebar-primary": "oklch(0.4891 0 0)", + "sidebar-primary-foreground": "oklch(1 0 0)", + "sidebar-accent": "oklch(0.9067 0 0)", + "sidebar-accent-foreground": "oklch(0.3211 0 0)", + "sidebar-border": "oklch(0.8576 0 0)", + "sidebar-ring": "#4299e1", + }, + + // Dark mode colors + dark: { + background: "oklch(0.2178 0 0)", + foreground: "oklch(0.8853 0 0)", + card: "oklch(0.2435 0 0)", + "card-foreground": "oklch(0.8853 0 0)", + popover: "oklch(0.2435 0 0)", + "popover-foreground": "oklch(0.8853 0 0)", + primary: "#63b3ed", + "primary-foreground": "#0f1419", + secondary: "oklch(0.3092 0 0)", + "secondary-foreground": "oklch(0.8853 0 0)", + muted: "oklch(0.285 0 0)", + "muted-foreground": "oklch(0.5999 0 0)", + accent: "oklch(0.32 0 0)", + "accent-foreground": "oklch(1 0 0)", + destructive: "oklch(0.6591 0.153 22.1703)", + "destructive-foreground": "oklch(1 0 0)", + border: "oklch(0.329 0 0)", + input: "oklch(0.3092 0 0)", + ring: "#63b3ed", + "chart-1": "oklch(0.7058 0 0)", + "chart-2": "oklch(0.6714 0.0339 206.3482)", + "chart-3": "oklch(0.5452 0 0)", + "chart-4": "oklch(0.4604 0 0)", + "chart-5": "oklch(0.3715 0 0)", + sidebar: "oklch(0.2393 0 0)", + "sidebar-foreground": "oklch(0.8853 0 0)", + "sidebar-primary": "oklch(0.7058 0 0)", + "sidebar-primary-foreground": "oklch(0.2178 0 0)", + "sidebar-accent": "oklch(0.275 0 0)", + "sidebar-accent-foreground": "oklch(0.8853 0 0)", + "sidebar-border": "oklch(0.329 0 0)", + "sidebar-ring": "#63b3ed", + }, + }, +}; diff --git a/src/contexts/theme/index.ts b/src/contexts/theme/index.ts new file mode 100644 index 00000000..1d53d9d8 --- /dev/null +++ b/src/contexts/theme/index.ts @@ -0,0 +1,18 @@ +// Core theme functionality +export { ThemeProvider, useTheme } from "./theme-provider"; +export { ThemeScript } from "./theme-script"; +export { useThemeManagement } from "./use-theme-management"; + +// Theme utilities +export { applyThemeToElement, getPreferredColorScheme } from "./apply-theme"; +export { + fetchThemeFromUrl, + extractThemeColors, + THEME_URLS, +} from "./theme-utils"; +export { loadThemeFromStorage, saveThemeToStorage } from "./storage"; + +// Default theme and types +export { DEFAULT_THEME } from "./default-theme"; +export type { ThemeState, ThemePreset, ThemeContextType } from "./types"; +export type { FetchedTheme } from "./theme-utils"; diff --git a/src/contexts/theme/storage.ts b/src/contexts/theme/storage.ts new file mode 100644 index 00000000..e9de47e9 --- /dev/null +++ b/src/contexts/theme/storage.ts @@ -0,0 +1,64 @@ +import type { ThemeState } from "./types"; +import { DEFAULT_THEME } from "./default-theme"; +import { getPreferredColorScheme } from "./apply-theme"; + +const THEME_STORAGE_KEY = "toolkit-theme-state"; + +/** + * Loads theme state from localStorage + */ +export function loadThemeFromStorage(): ThemeState { + if (typeof window === "undefined") { + return DEFAULT_THEME; + } + + try { + const stored = localStorage.getItem(THEME_STORAGE_KEY); + if (!stored) { + // If no theme is stored, use default with system preference + return { + ...DEFAULT_THEME, + currentMode: getPreferredColorScheme(), + }; + } + + const parsed = JSON.parse(stored) as ThemeState; + + // Validate the structure + if ( + parsed && + typeof parsed === "object" && + parsed.currentMode && + parsed.cssVars?.theme && + parsed.cssVars?.light && + parsed.cssVars?.dark + ) { + return parsed; + } + + // If invalid, return default + return { + ...DEFAULT_THEME, + currentMode: getPreferredColorScheme(), + }; + } catch (error) { + console.warn("Failed to load theme from storage:", error); + return { + ...DEFAULT_THEME, + currentMode: getPreferredColorScheme(), + }; + } +} + +/** + * Saves theme state to localStorage + */ +export function saveThemeToStorage(themeState: ThemeState): void { + if (typeof window === "undefined") return; + + try { + localStorage.setItem(THEME_STORAGE_KEY, JSON.stringify(themeState)); + } catch (error) { + console.warn("Failed to save theme to storage:", error); + } +} diff --git a/src/contexts/theme/theme-provider.tsx b/src/contexts/theme/theme-provider.tsx new file mode 100644 index 00000000..657cf78b --- /dev/null +++ b/src/contexts/theme/theme-provider.tsx @@ -0,0 +1,90 @@ +"use client"; + +import React, { + createContext, + useContext, + useEffect, + useState, + useCallback, +} from "react"; +import { applyThemeToElement } from "./apply-theme"; +import { DEFAULT_THEME } from "./default-theme"; +import { loadThemeFromStorage, saveThemeToStorage } from "./storage"; +import type { ThemeState, ThemePreset, ThemeContextType } from "./types"; + +const ThemeContext = createContext(null); + +interface ThemeProviderProps { + children: React.ReactNode; +} + +export function ThemeProvider({ children }: ThemeProviderProps) { + const [themeState, setThemeStateInternal] = + useState(DEFAULT_THEME); + const [isInitialized, setIsInitialized] = useState(false); + + // Load theme from storage on mount + useEffect(() => { + const storedTheme = loadThemeFromStorage(); + setThemeStateInternal(storedTheme); + setIsInitialized(true); + }, []); + + // Apply theme to DOM whenever theme state changes + useEffect(() => { + if (isInitialized) { + applyThemeToElement(themeState); + } + }, [themeState, isInitialized]); + + // Save theme to storage whenever it changes + const setThemeState = useCallback((newState: ThemeState) => { + setThemeStateInternal(newState); + saveThemeToStorage(newState); + }, []); + + const toggleMode = useCallback(() => { + setThemeState({ + ...themeState, + currentMode: themeState.currentMode === "light" ? "dark" : "light", + }); + }, [themeState, setThemeState]); + + const applyTheme = useCallback( + (preset: ThemePreset) => { + setThemeState({ + currentMode: themeState.currentMode, // Keep current mode + cssVars: preset.cssVars, + }); + }, + [themeState.currentMode, setThemeState], + ); + + const resetToDefault = useCallback(() => { + setThemeState({ + ...DEFAULT_THEME, + currentMode: themeState.currentMode, // Keep current mode + }); + }, [themeState.currentMode, setThemeState]); + + const value: ThemeContextType = { + themeState, + setThemeState, + currentMode: themeState.currentMode, + toggleMode, + applyTheme, + resetToDefault, + }; + + return ( + {children} + ); +} + +export function useTheme() { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/contexts/theme/theme-script.tsx b/src/contexts/theme/theme-script.tsx new file mode 100644 index 00000000..80b40d86 --- /dev/null +++ b/src/contexts/theme/theme-script.tsx @@ -0,0 +1,65 @@ +"use client"; + +import { DEFAULT_THEME } from "./default-theme"; + +/** + * Inline script component that runs before React hydration to prevent theme flash. + * This reads the stored theme from localStorage and applies it immediately. + */ +export function ThemeScript() { + const themeScript = ` +(function() { + try { + var THEME_STORAGE_KEY = "toolkit-theme-state"; + var stored = localStorage.getItem(THEME_STORAGE_KEY); + var themeState; + + if (stored) { + try { + themeState = JSON.parse(stored); + } catch (e) { + themeState = null; + } + } + + // If no valid stored theme, use default with system preference + if (!themeState || !themeState.currentMode || !themeState.cssVars) { + var prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; + themeState = { + currentMode: prefersDark ? "dark" : "light", + cssVars: ${JSON.stringify(DEFAULT_THEME.cssVars)} + }; + } + + // Apply theme variables immediately with !important to override CSS defaults + var element = document.documentElement; + + // Apply global theme variables + Object.entries(themeState.cssVars.theme || {}).forEach(function(entry) { + element.style.setProperty("--" + entry[0], entry[1], "important"); + }); + + // Apply mode-specific variables + var modeVars = themeState.cssVars[themeState.currentMode] || {}; + Object.entries(modeVars).forEach(function(entry) { + element.style.setProperty("--" + entry[0], entry[1], "important"); + }); + + // Set data attribute and classes + element.setAttribute("data-theme", themeState.currentMode); + element.classList.toggle("light", themeState.currentMode === "light"); + element.classList.toggle("dark", themeState.currentMode === "dark"); + } catch (error) { + console.warn("Theme script error:", error); + } +})(); +`; + + return ( +