From ab03ba957429096e3c6b0cc038c3043badc5e104 Mon Sep 17 00:00:00 2001 From: Shivanand Hulikatti <01fe23bcs268@kletech.ac.in> Date: Fri, 15 Aug 2025 10:45:04 +0530 Subject: [PATCH 1/6] themes added --- .../_components/landing-page/navbar/index.tsx | 2 + .../(general)/_components/sidebar/user.tsx | 31 +- .../_components/navbar/color-mode-toggle.tsx | 12 +- src/app/_components/navbar/index.tsx | 2 + src/app/layout.tsx | 20 +- src/components/theme/index.ts | 1 + src/components/theme/theme-switcher.tsx | 330 ++++++++++++++++++ src/contexts/theme/apply-theme.ts | 40 +++ src/contexts/theme/default-theme.ts | 99 ++++++ src/contexts/theme/index.ts | 18 + src/contexts/theme/storage.ts | 65 ++++ src/contexts/theme/theme-provider.tsx | 85 +++++ src/contexts/theme/theme-script.tsx | 67 ++++ src/contexts/theme/theme-utils.ts | 114 ++++++ src/contexts/theme/types.ts | 27 ++ src/contexts/theme/use-theme-management.ts | 156 +++++++++ 16 files changed, 1050 insertions(+), 19 deletions(-) create mode 100644 src/components/theme/index.ts create mode 100644 src/components/theme/theme-switcher.tsx create mode 100644 src/contexts/theme/apply-theme.ts create mode 100644 src/contexts/theme/default-theme.ts create mode 100644 src/contexts/theme/index.ts create mode 100644 src/contexts/theme/storage.ts create mode 100644 src/contexts/theme/theme-provider.tsx create mode 100644 src/contexts/theme/theme-script.tsx create mode 100644 src/contexts/theme/theme-utils.ts create mode 100644 src/contexts/theme/types.ts create mode 100644 src/contexts/theme/use-theme-management.ts diff --git a/src/app/(general)/_components/landing-page/navbar/index.tsx b/src/app/(general)/_components/landing-page/navbar/index.tsx index 9f0e8fb2..f0d13d79 100644 --- a/src/app/(general)/_components/landing-page/navbar/index.tsx +++ b/src/app/(general)/_components/landing-page/navbar/index.tsx @@ -1,6 +1,7 @@ import { Logo } from "@/components/ui/logo"; import { HStack } from "@/components/ui/stack"; import { ColorModeToggle } from "../../../../_components/navbar/color-mode-toggle"; +import { ThemeSwitcher } from "@/components/theme"; import { Button } from "@/components/ui/button"; import { AuthModal } from "../lib/auth-modal"; import { SiDiscord, SiGithub } from "@icons-pack/react-simple-icons"; @@ -33,6 +34,7 @@ export const Navbar = () => { + diff --git a/src/app/(general)/_components/sidebar/user.tsx b/src/app/(general)/_components/sidebar/user.tsx index 867f079e..988fecef 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,19 +93,23 @@ 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 Switcher + + signOut()}> diff --git a/src/app/_components/navbar/color-mode-toggle.tsx b/src/app/_components/navbar/color-mode-toggle.tsx index fd31c90a..7a89cb4d 100644 --- a/src/app/_components/navbar/color-mode-toggle.tsx +++ b/src/app/_components/navbar/color-mode-toggle.tsx @@ -1,19 +1,27 @@ "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 ( <> + + )} + + + + + Theme Switcher + + + +
+ {/* 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: any; + 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(""); + await 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..79295a72 --- /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..dbe35ada --- /dev/null +++ b/src/contexts/theme/default-theme.ts @@ -0,0 +1,99 @@ +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..4ac84469 --- /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..70e59550 --- /dev/null +++ b/src/contexts/theme/storage.ts @@ -0,0 +1,65 @@ +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 && + 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..76cbde75 --- /dev/null +++ b/src/contexts/theme/theme-provider.tsx @@ -0,0 +1,85 @@ +"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..9b4bc9ef --- /dev/null +++ b/src/contexts/theme/theme-script.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { applyThemeToElement, getPreferredColorScheme } from "./apply-theme"; +import { DEFAULT_THEME } from "./default-theme"; +import type { ThemeState } from "./types"; + +/** + * 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 ( +