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 ( <> setTheme(theme === "light" ? "dark" : "light")} + onClick={handleToggle} variant="outline" - aria-label={`Switch to ${theme === "light" ? "dark" : "light"} mode`} + aria-label={`Switch to ${currentMode === "light" ? "dark" : "light"} mode`} size="icon" suppressHydrationWarning className="relative size-8" diff --git a/src/app/_components/navbar/index.tsx b/src/app/_components/navbar/index.tsx index 13c865ad..901cd2f9 100644 --- a/src/app/_components/navbar/index.tsx +++ b/src/app/_components/navbar/index.tsx @@ -5,6 +5,7 @@ import { ColorModeToggle } from "./color-mode-toggle"; import { HStack } from "@/components/ui/stack"; import { auth } from "@/server/auth"; import { SidebarTrigger } from "@/components/ui/sidebar"; +import { ThemeSwitcher } from "@/components/theme"; import { Menu } from "lucide-react"; export const Navbar = async () => { @@ -26,6 +27,7 @@ export const Navbar = async () => { + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8fcba0a..66cb13d3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { TRPCReactProvider } from "@/trpc/react"; import { Toaster } from "@/components/ui/sonner"; import { EnvProvider } from "@/contexts/env"; +import { ThemeProvider as CustomThemeProvider, ThemeScript } from "@/contexts/theme"; import { env } from "@/env"; @@ -57,6 +58,7 @@ export default async function RootLayout({ > + @@ -64,14 +66,16 @@ export default async function RootLayout({ - - {children} - + + + {children} + + diff --git a/src/components/theme/index.ts b/src/components/theme/index.ts new file mode 100644 index 00000000..66408c90 --- /dev/null +++ b/src/components/theme/index.ts @@ -0,0 +1 @@ +export { ThemeSwitcher } from "./theme-switcher"; diff --git a/src/components/theme/theme-switcher.tsx b/src/components/theme/theme-switcher.tsx new file mode 100644 index 00000000..ec0e8789 --- /dev/null +++ b/src/components/theme/theme-switcher.tsx @@ -0,0 +1,330 @@ +"use client"; + +import React, { useState } from "react"; +import { Search, Palette, Shuffle, Moon, Sun, Plus, X, ExternalLink } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; + +import { useThemeManagement } from "@/contexts/theme"; +import { cn } from "@/lib/utils"; + +interface ThemeSwitcherProps { + children?: React.ReactNode; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: ThemeSwitcherProps) { + const { + currentMode, + allThemes, + builtInThemes, + customThemes, + isLoading, + toggleMode, + applyPreset, + randomizeTheme, + resetToDefault, + getThemePreview, + } = useThemeManagement(); + + const [searchQuery, setSearchQuery] = useState(""); + const [internalOpen, setInternalOpen] = useState(false); + const [showImportDialog, setShowImportDialog] = useState(false); + + // Use controlled or uncontrolled state + const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; + const setIsOpen = onOpenChange || setInternalOpen; + + // Filter themes based on search + const filteredThemes = allThemes.filter((theme) => + theme.name.toLowerCase().includes(searchQuery.toLowerCase()), + ); + + return ( + + {children && {children}} + {!children && ( + + + + + + )} + + + + + Theme Switcher + + + + + {/* Controls */} + + + + setSearchQuery(e.target.value)} + className="pl-9" + /> + + + + + + + {currentMode === "light" ? ( + + ) : ( + + )} + + + + Switch to {currentMode === "light" ? "dark" : "light"} mode + + + + + + + + + + Random theme + + + + + setShowImportDialog(true)} + > + + + + 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 */} + + + Reset to Default + + + {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} available + + + + + + + + ); +} + +interface ThemeCardProps { + theme: any; + onSelect: () => void; + previewColors: string[]; +} + +function ThemeCard({ theme, onSelect, previewColors }: ThemeCardProps) { + return ( + + + + {theme.name} + {!theme.isBuiltIn && Custom} + + + {/* Color Preview */} + + {previewColors.slice(0, 4).map((color, index) => ( + + ))} + + + + ); +} + +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 + + + + + + Theme URL + { + setUrl(e.target.value); + setError(""); + }} + className="mt-1" + /> + {error && {error}} + + + + Supported formats: + + tweakcn.com theme URLs + Direct JSON theme files + + + + + onOpenChange(false)}> + Cancel + + + {isImporting ? "Importing..." : "Import Theme"} + + + + + + ); +} 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 ( + + ); +} diff --git a/src/contexts/theme/theme-utils.ts b/src/contexts/theme/theme-utils.ts new file mode 100644 index 00000000..d51089bb --- /dev/null +++ b/src/contexts/theme/theme-utils.ts @@ -0,0 +1,114 @@ +import type { ThemePreset } from "./types"; + +// Built-in theme URLs from tweakcn.com +export const THEME_URLS = [ + "https://tweakcn.com/themes/cmc335y45000n04ld51zg72j3", + "https://tweakcn.com/editor/theme?theme=mono", + "https://tweakcn.com/editor/theme?theme=t3-chat", + "https://tweakcn.com/editor/theme?theme=tangerine", + "https://tweakcn.com/editor/theme?theme=perpetuity", + "https://tweakcn.com/editor/theme?theme=modern-minimal", + "https://tweakcn.com/r/themes/vintage-paper.json", + "https://tweakcn.com/r/themes/amethyst-haze.json", + "https://tweakcn.com/editor/theme?theme=caffeine", + "https://tweakcn.com/editor/theme?theme=quantum-rose", + "https://tweakcn.com/editor/theme?theme=claymorphism", + "https://tweakcn.com/editor/theme?theme=pastel-dreams", + "https://tweakcn.com/editor/theme?theme=supabase", + "https://tweakcn.com/editor/theme?theme=vercel", + "https://tweakcn.com/editor/theme?theme=cyberpunk", +]; + +export type FetchedTheme = { + name: string; + preset: ThemePreset; + url: string; + error?: string; + type: "custom" | "built-in"; +}; + +/** + * Converts a tweakcn editor URL to the JSON API endpoint + */ +function normalizeThemeUrl(url: string): string { + const baseUrl = "https://tweakcn.com/r/themes/"; + const isBuiltInUrl = url.includes("editor/theme?theme="); + + return url + .replace("https://tweakcn.com/editor/theme?theme=", baseUrl) + .replace("https://tweakcn.com/themes/", baseUrl) + (isBuiltInUrl ? ".json" : ""); +} + +/** + * Gets the theme name from the data or generates a fallback + */ +function getThemeName(themeData: any): string { + return themeData.name + ? themeData.name.replace(/[-_]/g, " ").replace(/\b\w/g, (l: string) => l.toUpperCase()) + : "Custom Theme"; +} + +/** + * Fetches a theme from a URL and converts it to ThemePreset format + */ +export async function fetchThemeFromUrl(url: string): Promise { + const transformedUrl = normalizeThemeUrl(url); + + try { + const response = await fetch(transformedUrl); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const themeData = await response.json(); + const themeName = getThemeName(themeData); + const isBuiltIn = THEME_URLS.includes(url); + + return { + name: themeName, + preset: { + name: themeName, + isBuiltIn, + cssVars: themeData.cssVars || { theme: {}, light: {}, dark: {} } + }, + url, + type: isBuiltIn ? "built-in" : "custom", + }; + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Failed to fetch theme"; + const themeName = getThemeName({}); + const isBuiltIn = THEME_URLS.includes(url); + + return { + name: themeName, + preset: { + name: themeName, + isBuiltIn, + cssVars: { theme: {}, light: {}, dark: {} } + }, + url, + error: errorMessage, + type: isBuiltIn ? "built-in" : "custom", + }; + } +} + +/** + * Extracts color swatches for theme preview + */ +export function extractThemeColors(preset: ThemePreset, mode: "light" | "dark" = "light"): string[] { + const { light, dark, theme } = preset.cssVars; + const currentVars = { ...theme, ...(mode === "light" ? light : dark) }; + + const colorKeys = ["primary", "accent", "secondary", "background", "muted"]; + const colors: string[] = []; + + colorKeys.forEach((key) => { + const colorValue = currentVars[key]; + if (colorValue && colors.length < 5) { + colors.push(colorValue.includes("hsl") ? `hsl(${colorValue})` : colorValue); + } + }); + + return colors; +} diff --git a/src/contexts/theme/types.ts b/src/contexts/theme/types.ts new file mode 100644 index 00000000..8f7e5144 --- /dev/null +++ b/src/contexts/theme/types.ts @@ -0,0 +1,27 @@ +export interface ThemeState { + currentMode: "light" | "dark"; + cssVars: { + theme: Record; + light: Record; + dark: Record; + }; +} + +export interface ThemePreset { + name: string; + isBuiltIn: boolean; + cssVars: { + theme: Record; + light: Record; + dark: Record; + }; +} + +export interface ThemeContextType { + themeState: ThemeState; + setThemeState: (state: ThemeState) => void; + currentMode: "light" | "dark"; + toggleMode: () => void; + applyTheme: (preset: ThemePreset) => void; + resetToDefault: () => void; +} diff --git a/src/contexts/theme/use-theme-management.ts b/src/contexts/theme/use-theme-management.ts new file mode 100644 index 00000000..b0e2eead --- /dev/null +++ b/src/contexts/theme/use-theme-management.ts @@ -0,0 +1,156 @@ +"use client"; + +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; + +import { useTheme } from "./theme-provider"; +import { fetchThemeFromUrl, THEME_URLS, extractThemeColors, type FetchedTheme } from "./theme-utils"; +import type { ThemePreset } from "./types"; + +/** + * Hook for managing themes including fetching, applying, and custom imports + */ +export function useThemeManagement() { + const { themeState, setThemeState, toggleMode, applyTheme, resetToDefault } = useTheme(); + const queryClient = useQueryClient(); + const [customThemeUrls, setCustomThemeUrls] = useState([]); + + // Fetch built-in themes + const { + data: builtInThemes = [], + isLoading: isLoadingBuiltIn, + error: builtInError, + } = useQuery({ + queryKey: ["themes", "built-in"], + queryFn: async () => { + const results = await Promise.allSettled(THEME_URLS.map(fetchThemeFromUrl)); + + return results + .filter((result): result is PromiseFulfilledResult => + result.status === "fulfilled" && !result.value.error + ) + .map((result) => ({ + ...result.value.preset, + name: result.value.name, + isBuiltIn: true, + } as ThemePreset)); + }, + staleTime: 1000 * 60 * 30, // 30 minutes + }); + + // Fetch custom themes + const { + data: customThemes = [], + isLoading: isLoadingCustom, + error: customError, + } = useQuery({ + queryKey: ["themes", "custom", customThemeUrls], + queryFn: async () => { + if (customThemeUrls.length === 0) return []; + + const results = await Promise.allSettled(customThemeUrls.map(fetchThemeFromUrl)); + + return results + .filter((result): result is PromiseFulfilledResult => + result.status === "fulfilled" && !result.value.error + ) + .map((result) => ({ + ...result.value.preset, + name: result.value.name, + isBuiltIn: false, + } as ThemePreset)); + }, + enabled: customThemeUrls.length > 0, + staleTime: 1000 * 60 * 5, // 5 minutes for custom themes + }); + + // Import theme mutation + const importThemeMutation = useMutation({ + mutationFn: async (url: string) => { + const fetchedTheme = await fetchThemeFromUrl(url); + if (fetchedTheme.error) { + throw new Error(fetchedTheme.error); + } + return { theme: fetchedTheme.preset, url, name: fetchedTheme.name }; + }, + onSuccess: ({ theme, url, name }) => { + applyTheme(theme); + + // Add to custom themes if not already there + if (!customThemeUrls.includes(url)) { + setCustomThemeUrls((prev) => [...prev, url]); + } + + // Invalidate queries to refresh the lists + queryClient.invalidateQueries({ queryKey: ["themes"] }); + + toast.success(`Applied theme: ${name}`); + }, + onError: (error) => { + toast.error(`Failed to import theme: ${error.message}`); + }, + }); + + // Get all themes combined + const allThemes = [...builtInThemes, ...customThemes]; + const isLoading = isLoadingBuiltIn || isLoadingCustom || importThemeMutation.isPending; + + // Apply a preset theme + const applyPreset = (preset: ThemePreset) => { + applyTheme(preset); + toast.success(`Applied theme: ${preset.name}`); + }; + + // Randomize theme + const randomizeTheme = () => { + if (allThemes.length === 0) return; + + const randomTheme = allThemes[Math.floor(Math.random() * allThemes.length)]; + if (randomTheme) { + applyPreset(randomTheme); + } + }; + + // Remove custom theme URL + const removeCustomTheme = (url: string) => { + setCustomThemeUrls((prev) => prev.filter((u) => u !== url)); + queryClient.invalidateQueries({ queryKey: ["themes", "custom"] }); + toast.success("Removed custom theme"); + }; + + // Get preview colors for a theme + const getThemePreview = (preset: ThemePreset) => { + return extractThemeColors(preset, themeState.currentMode); + }; + + return { + // State + currentMode: themeState.currentMode, + themeState, + + // Themes + builtInThemes, + customThemes, + allThemes, + isLoading, + + // Actions + toggleMode, + applyPreset, + randomizeTheme, + resetToDefault, + + // Custom theme management + importTheme: importThemeMutation.mutate, + isImporting: importThemeMutation.isPending, + removeCustomTheme, + customThemeUrls, + + // Utilities + getThemePreview, + + // Errors + error: builtInError || customError, + }; +} From 2802066c68863c7cefb29594319016c8da8d7953 Mon Sep 17 00:00:00 2001 From: Shivanand Hulikatti <01fe23bcs268@kletech.ac.in> Date: Fri, 15 Aug 2025 16:53:09 +0530 Subject: [PATCH 2/6] Fixed ui issues and renamed to appearance --- src/app/(general)/_components/landing-page/navbar/index.tsx | 4 +++- src/app/(general)/_components/sidebar/user.tsx | 2 +- src/app/_components/navbar/color-mode-toggle.tsx | 2 +- src/components/theme/theme-switcher.tsx | 4 ++-- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/app/(general)/_components/landing-page/navbar/index.tsx b/src/app/(general)/_components/landing-page/navbar/index.tsx index f0d13d79..671d4dee 100644 --- a/src/app/(general)/_components/landing-page/navbar/index.tsx +++ b/src/app/(general)/_components/landing-page/navbar/index.tsx @@ -35,7 +35,9 @@ export const Navbar = () => { - + + + diff --git a/src/app/(general)/_components/sidebar/user.tsx b/src/app/(general)/_components/sidebar/user.tsx index 988fecef..18c01041 100644 --- a/src/app/(general)/_components/sidebar/user.tsx +++ b/src/app/(general)/_components/sidebar/user.tsx @@ -107,7 +107,7 @@ export function NavUser({ onClick={(e) => e.stopPropagation()} > - Theme Switcher + Appearance diff --git a/src/app/_components/navbar/color-mode-toggle.tsx b/src/app/_components/navbar/color-mode-toggle.tsx index 7a89cb4d..ac02245c 100644 --- a/src/app/_components/navbar/color-mode-toggle.tsx +++ b/src/app/_components/navbar/color-mode-toggle.tsx @@ -24,7 +24,7 @@ export const ColorModeToggle = () => { aria-label={`Switch to ${currentMode === "light" ? "dark" : "light"} mode`} size="icon" suppressHydrationWarning - className="relative size-8" + className="relative size-9" > diff --git a/src/components/theme/theme-switcher.tsx b/src/components/theme/theme-switcher.tsx index ec0e8789..83618906 100644 --- a/src/components/theme/theme-switcher.tsx +++ b/src/components/theme/theme-switcher.tsx @@ -71,7 +71,7 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: - Theme Switcher + Appearance @@ -130,7 +130,7 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: {/* Theme Grid */} - + {/* Built-in Themes */} {builtInThemes.length > 0 && ( From d14fef5cae5d5133cf95213b697cab592d186814 Mon Sep 17 00:00:00 2001 From: Shivanand Hulikatti <01fe23bcs268@kletech.ac.in> Date: Fri, 15 Aug 2025 19:44:24 +0530 Subject: [PATCH 3/6] fixed formatting issues --- .../(general)/_components/sidebar/user.tsx | 2 +- src/app/layout.tsx | 5 +- src/components/theme/theme-switcher.tsx | 60 +++++++++----- src/contexts/theme/apply-theme.ts | 8 +- src/contexts/theme/default-theme.ts | 77 +++++++++--------- src/contexts/theme/index.ts | 6 +- src/contexts/theme/storage.ts | 4 +- src/contexts/theme/theme-provider.tsx | 15 ++-- src/contexts/theme/theme-utils.ts | 49 +++++++----- src/contexts/theme/use-theme-management.ts | 79 ++++++++++++------- 10 files changed, 186 insertions(+), 119 deletions(-) diff --git a/src/app/(general)/_components/sidebar/user.tsx b/src/app/(general)/_components/sidebar/user.tsx index 18c01041..94883a7b 100644 --- a/src/app/(general)/_components/sidebar/user.tsx +++ b/src/app/(general)/_components/sidebar/user.tsx @@ -102,7 +102,7 @@ export function NavUser({ {currentMode === "light" ? "Light mode" : "Dark mode"} - e.preventDefault()} onClick={(e) => e.stopPropagation()} > diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 66cb13d3..29931ac3 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,7 +9,10 @@ import { TRPCReactProvider } from "@/trpc/react"; import { Toaster } from "@/components/ui/sonner"; import { EnvProvider } from "@/contexts/env"; -import { ThemeProvider as CustomThemeProvider, ThemeScript } from "@/contexts/theme"; +import { + ThemeProvider as CustomThemeProvider, + ThemeScript, +} from "@/contexts/theme"; import { env } from "@/env"; diff --git a/src/components/theme/theme-switcher.tsx b/src/components/theme/theme-switcher.tsx index 83618906..cb5a05a5 100644 --- a/src/components/theme/theme-switcher.tsx +++ b/src/components/theme/theme-switcher.tsx @@ -1,7 +1,16 @@ "use client"; import React, { useState } from "react"; -import { Search, Palette, Shuffle, Moon, Sun, Plus, X, ExternalLink } from "lucide-react"; +import { + Search, + Palette, + Shuffle, + Moon, + Sun, + Plus, + X, + ExternalLink, +} from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -30,7 +39,11 @@ interface ThemeSwitcherProps { onOpenChange?: (open: boolean) => void; } -export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: ThemeSwitcherProps) { +export function ThemeSwitcher({ + children, + open: controlledOpen, + onOpenChange, +}: ThemeSwitcherProps) { const { currentMode, allThemes, @@ -79,7 +92,7 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: {/* Controls */} - + - + @@ -106,7 +119,11 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: - + @@ -134,13 +151,15 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: {/* Built-in Themes */} {builtInThemes.length > 0 && ( - + Built-in Themes {builtInThemes .filter((theme) => - theme.name.toLowerCase().includes(searchQuery.toLowerCase()), + theme.name + .toLowerCase() + .includes(searchQuery.toLowerCase()), ) .map((theme) => ( 0 && ( - + Custom Themes {customThemes .filter((theme) => - theme.name.toLowerCase().includes(searchQuery.toLowerCase()), + theme.name + .toLowerCase() + .includes(searchQuery.toLowerCase()), ) .map((theme) => ( - Loading themes... + + Loading themes... + )} {!isLoading && filteredThemes.length === 0 && searchQuery && ( - + No themes found matching "{searchQuery}" @@ -204,8 +227,9 @@ export function ThemeSwitcher({ children, open: controlledOpen, onOpenChange }: Reset to Default - - {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""} available + + {allThemes.length} theme{allThemes.length !== 1 ? "s" : ""}{" "} + available @@ -232,7 +256,7 @@ function ThemeCard({ theme, onSelect, previewColors }: ThemeCardProps) { className={cn( "group relative rounded-lg border p-3 text-left transition-all", "hover:border-primary hover:shadow-md", - "focus:outline-none focus:ring-1 focus:ring-primary focus:ring-offset-1", + "focus:ring-primary focus:ring-1 focus:ring-offset-1 focus:outline-none", )} > @@ -240,13 +264,13 @@ function ThemeCard({ theme, onSelect, previewColors }: ThemeCardProps) { {theme.name} {!theme.isBuiltIn && Custom} - + {/* Color Preview */} {previewColors.slice(0, 4).map((color, index) => ( ))} @@ -304,10 +328,10 @@ function ImportThemeDialog({ open, onOpenChange }: ImportThemeDialogProps) { }} className="mt-1" /> - {error && {error}} + {error && {error}} - + Supported formats: tweakcn.com theme URLs diff --git a/src/contexts/theme/apply-theme.ts b/src/contexts/theme/apply-theme.ts index 79295a72..6db6fa23 100644 --- a/src/contexts/theme/apply-theme.ts +++ b/src/contexts/theme/apply-theme.ts @@ -22,7 +22,7 @@ export function applyThemeToElement( // 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"); @@ -33,8 +33,8 @@ export function applyThemeToElement( */ export function getPreferredColorScheme(): "light" | "dark" { if (typeof window === "undefined") return "light"; - - return window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" + + 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 index dbe35ada..db74b8f3 100644 --- a/src/contexts/theme/default-theme.ts +++ b/src/contexts/theme/default-theme.ts @@ -9,48 +9,53 @@ export const DEFAULT_THEME: ThemeState = { "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", - + 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-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)", + 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: "oklch(0.9702 0 0)", "popover-foreground": "oklch(0.3211 0 0)", - "primary": "#4299e1", + primary: "#4299e1", "primary-foreground": "#ffffff", - "secondary": "oklch(0.9067 0 0)", + secondary: "oklch(0.9067 0 0)", "secondary-foreground": "oklch(0.3211 0 0)", - "muted": "oklch(0.9067 0 0)", + muted: "oklch(0.9067 0 0)", "muted-foreground": "oklch(0.5103 0 0)", - "accent": "oklch(0.94 0 0)", + accent: "oklch(0.94 0 0)", "accent-foreground": "oklch(0 0 0)", - "destructive": "oklch(0.5594 0.19 25.8625)", + 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", + 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: "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)", @@ -59,34 +64,34 @@ export const DEFAULT_THEME: ThemeState = { "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)", + 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: "oklch(0.2435 0 0)", "popover-foreground": "oklch(0.8853 0 0)", - "primary": "#63b3ed", + primary: "#63b3ed", "primary-foreground": "#0f1419", - "secondary": "oklch(0.3092 0 0)", + secondary: "oklch(0.3092 0 0)", "secondary-foreground": "oklch(0.8853 0 0)", - "muted": "oklch(0.285 0 0)", + muted: "oklch(0.285 0 0)", "muted-foreground": "oklch(0.5999 0 0)", - "accent": "oklch(0.32 0 0)", + accent: "oklch(0.32 0 0)", "accent-foreground": "oklch(1 0 0)", - "destructive": "oklch(0.6591 0.153 22.1703)", + 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", + 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: "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)", diff --git a/src/contexts/theme/index.ts b/src/contexts/theme/index.ts index 4ac84469..1d53d9d8 100644 --- a/src/contexts/theme/index.ts +++ b/src/contexts/theme/index.ts @@ -5,10 +5,10 @@ export { useThemeManagement } from "./use-theme-management"; // Theme utilities export { applyThemeToElement, getPreferredColorScheme } from "./apply-theme"; -export { - fetchThemeFromUrl, +export { + fetchThemeFromUrl, extractThemeColors, - THEME_URLS + THEME_URLS, } from "./theme-utils"; export { loadThemeFromStorage, saveThemeToStorage } from "./storage"; diff --git a/src/contexts/theme/storage.ts b/src/contexts/theme/storage.ts index 70e59550..4cdae779 100644 --- a/src/contexts/theme/storage.ts +++ b/src/contexts/theme/storage.ts @@ -23,7 +23,7 @@ export function loadThemeFromStorage(): ThemeState { } const parsed = JSON.parse(stored) as ThemeState; - + // Validate the structure if ( parsed && @@ -36,7 +36,7 @@ export function loadThemeFromStorage(): ThemeState { ) { return parsed; } - + // If invalid, return default return { ...DEFAULT_THEME, diff --git a/src/contexts/theme/theme-provider.tsx b/src/contexts/theme/theme-provider.tsx index 76cbde75..657cf78b 100644 --- a/src/contexts/theme/theme-provider.tsx +++ b/src/contexts/theme/theme-provider.tsx @@ -1,6 +1,12 @@ "use client"; -import React, { createContext, useContext, useEffect, useState, useCallback } from "react"; +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"; @@ -13,7 +19,8 @@ interface ThemeProviderProps { } export function ThemeProvider({ children }: ThemeProviderProps) { - const [themeState, setThemeStateInternal] = useState(DEFAULT_THEME); + const [themeState, setThemeStateInternal] = + useState(DEFAULT_THEME); const [isInitialized, setIsInitialized] = useState(false); // Load theme from storage on mount @@ -70,9 +77,7 @@ export function ThemeProvider({ children }: ThemeProviderProps) { }; return ( - - {children} - + {children} ); } diff --git a/src/contexts/theme/theme-utils.ts b/src/contexts/theme/theme-utils.ts index d51089bb..32ed7a12 100644 --- a/src/contexts/theme/theme-utils.ts +++ b/src/contexts/theme/theme-utils.ts @@ -33,18 +33,23 @@ export type FetchedTheme = { function normalizeThemeUrl(url: string): string { const baseUrl = "https://tweakcn.com/r/themes/"; const isBuiltInUrl = url.includes("editor/theme?theme="); - - return url - .replace("https://tweakcn.com/editor/theme?theme=", baseUrl) - .replace("https://tweakcn.com/themes/", baseUrl) + (isBuiltInUrl ? ".json" : ""); + + return ( + url + .replace("https://tweakcn.com/editor/theme?theme=", baseUrl) + .replace("https://tweakcn.com/themes/", baseUrl) + + (isBuiltInUrl ? ".json" : "") + ); } /** * Gets the theme name from the data or generates a fallback */ function getThemeName(themeData: any): string { - return themeData.name - ? themeData.name.replace(/[-_]/g, " ").replace(/\b\w/g, (l: string) => l.toUpperCase()) + return themeData.name + ? themeData.name + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (l: string) => l.toUpperCase()) : "Custom Theme"; } @@ -53,38 +58,39 @@ function getThemeName(themeData: any): string { */ export async function fetchThemeFromUrl(url: string): Promise { const transformedUrl = normalizeThemeUrl(url); - + try { const response = await fetch(transformedUrl); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - + const themeData = await response.json(); const themeName = getThemeName(themeData); const isBuiltIn = THEME_URLS.includes(url); - + return { name: themeName, preset: { name: themeName, isBuiltIn, - cssVars: themeData.cssVars || { theme: {}, light: {}, dark: {} } + cssVars: themeData.cssVars || { theme: {}, light: {}, dark: {} }, }, url, type: isBuiltIn ? "built-in" : "custom", }; } catch (err) { - const errorMessage = err instanceof Error ? err.message : "Failed to fetch theme"; + const errorMessage = + err instanceof Error ? err.message : "Failed to fetch theme"; const themeName = getThemeName({}); const isBuiltIn = THEME_URLS.includes(url); - + return { name: themeName, - preset: { + preset: { name: themeName, isBuiltIn, - cssVars: { theme: {}, light: {}, dark: {} } + cssVars: { theme: {}, light: {}, dark: {} }, }, url, error: errorMessage, @@ -96,19 +102,24 @@ export async function fetchThemeFromUrl(url: string): Promise { /** * Extracts color swatches for theme preview */ -export function extractThemeColors(preset: ThemePreset, mode: "light" | "dark" = "light"): string[] { +export function extractThemeColors( + preset: ThemePreset, + mode: "light" | "dark" = "light", +): string[] { const { light, dark, theme } = preset.cssVars; const currentVars = { ...theme, ...(mode === "light" ? light : dark) }; - + const colorKeys = ["primary", "accent", "secondary", "background", "muted"]; const colors: string[] = []; - + colorKeys.forEach((key) => { const colorValue = currentVars[key]; if (colorValue && colors.length < 5) { - colors.push(colorValue.includes("hsl") ? `hsl(${colorValue})` : colorValue); + colors.push( + colorValue.includes("hsl") ? `hsl(${colorValue})` : colorValue, + ); } }); - + return colors; } diff --git a/src/contexts/theme/use-theme-management.ts b/src/contexts/theme/use-theme-management.ts index b0e2eead..faa1ae0e 100644 --- a/src/contexts/theme/use-theme-management.ts +++ b/src/contexts/theme/use-theme-management.ts @@ -5,14 +5,20 @@ import { useState } from "react"; import { toast } from "sonner"; import { useTheme } from "./theme-provider"; -import { fetchThemeFromUrl, THEME_URLS, extractThemeColors, type FetchedTheme } from "./theme-utils"; +import { + fetchThemeFromUrl, + THEME_URLS, + extractThemeColors, + type FetchedTheme, +} from "./theme-utils"; import type { ThemePreset } from "./types"; /** * Hook for managing themes including fetching, applying, and custom imports */ export function useThemeManagement() { - const { themeState, setThemeState, toggleMode, applyTheme, resetToDefault } = useTheme(); + const { themeState, setThemeState, toggleMode, applyTheme, resetToDefault } = + useTheme(); const queryClient = useQueryClient(); const [customThemeUrls, setCustomThemeUrls] = useState([]); @@ -24,17 +30,23 @@ export function useThemeManagement() { } = useQuery({ queryKey: ["themes", "built-in"], queryFn: async () => { - const results = await Promise.allSettled(THEME_URLS.map(fetchThemeFromUrl)); - + const results = await Promise.allSettled( + THEME_URLS.map(fetchThemeFromUrl), + ); + return results - .filter((result): result is PromiseFulfilledResult => - result.status === "fulfilled" && !result.value.error + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled" && !result.value.error, ) - .map((result) => ({ - ...result.value.preset, - name: result.value.name, - isBuiltIn: true, - } as ThemePreset)); + .map( + (result) => + ({ + ...result.value.preset, + name: result.value.name, + isBuiltIn: true, + }) as ThemePreset, + ); }, staleTime: 1000 * 60 * 30, // 30 minutes }); @@ -49,17 +61,23 @@ export function useThemeManagement() { queryFn: async () => { if (customThemeUrls.length === 0) return []; - const results = await Promise.allSettled(customThemeUrls.map(fetchThemeFromUrl)); - + const results = await Promise.allSettled( + customThemeUrls.map(fetchThemeFromUrl), + ); + return results - .filter((result): result is PromiseFulfilledResult => - result.status === "fulfilled" && !result.value.error + .filter( + (result): result is PromiseFulfilledResult => + result.status === "fulfilled" && !result.value.error, ) - .map((result) => ({ - ...result.value.preset, - name: result.value.name, - isBuiltIn: false, - } as ThemePreset)); + .map( + (result) => + ({ + ...result.value.preset, + name: result.value.name, + isBuiltIn: false, + }) as ThemePreset, + ); }, enabled: customThemeUrls.length > 0, staleTime: 1000 * 60 * 5, // 5 minutes for custom themes @@ -76,15 +94,15 @@ export function useThemeManagement() { }, onSuccess: ({ theme, url, name }) => { applyTheme(theme); - + // Add to custom themes if not already there if (!customThemeUrls.includes(url)) { setCustomThemeUrls((prev) => [...prev, url]); } - + // Invalidate queries to refresh the lists queryClient.invalidateQueries({ queryKey: ["themes"] }); - + toast.success(`Applied theme: ${name}`); }, onError: (error) => { @@ -94,7 +112,8 @@ export function useThemeManagement() { // Get all themes combined const allThemes = [...builtInThemes, ...customThemes]; - const isLoading = isLoadingBuiltIn || isLoadingCustom || importThemeMutation.isPending; + const isLoading = + isLoadingBuiltIn || isLoadingCustom || importThemeMutation.isPending; // Apply a preset theme const applyPreset = (preset: ThemePreset) => { @@ -105,7 +124,7 @@ export function useThemeManagement() { // Randomize theme const randomizeTheme = () => { if (allThemes.length === 0) return; - + const randomTheme = allThemes[Math.floor(Math.random() * allThemes.length)]; if (randomTheme) { applyPreset(randomTheme); @@ -128,28 +147,28 @@ export function useThemeManagement() { // State currentMode: themeState.currentMode, themeState, - + // Themes builtInThemes, customThemes, allThemes, isLoading, - + // Actions toggleMode, applyPreset, randomizeTheme, resetToDefault, - + // Custom theme management importTheme: importThemeMutation.mutate, isImporting: importThemeMutation.isPending, removeCustomTheme, customThemeUrls, - + // Utilities getThemePreview, - + // Errors error: builtInError || customError, }; From 566ebbc2555a309e6e12eaab98d104bcd0c2a631 Mon Sep 17 00:00:00 2001 From: Shivanand Hulikatti <01fe23bcs268@kletech.ac.in> Date: Fri, 15 Aug 2025 21:51:59 +0530 Subject: [PATCH 4/6] fix format and linting issue --- src/components/theme/theme-switcher.tsx | 26 ++++++++------------- src/contexts/theme/storage.ts | 7 +++--- src/contexts/theme/theme-script.tsx | 2 -- src/contexts/theme/theme-utils.ts | 27 +++++++++++++++------- src/contexts/theme/use-theme-management.ts | 13 +++++------ 5 files changed, 38 insertions(+), 37 deletions(-) diff --git a/src/components/theme/theme-switcher.tsx b/src/components/theme/theme-switcher.tsx index cb5a05a5..e479a6d0 100644 --- a/src/components/theme/theme-switcher.tsx +++ b/src/components/theme/theme-switcher.tsx @@ -1,16 +1,8 @@ "use client"; import React, { useState } from "react"; -import { - Search, - Palette, - Shuffle, - Moon, - Sun, - Plus, - X, - ExternalLink, -} from "lucide-react"; +import type { ThemePreset } from "@/contexts/theme/types"; +import { Search, Palette, Shuffle, Moon, Sun, Plus } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -62,8 +54,8 @@ export function ThemeSwitcher({ const [showImportDialog, setShowImportDialog] = useState(false); // Use controlled or uncontrolled state - const isOpen = controlledOpen !== undefined ? controlledOpen : internalOpen; - const setIsOpen = onOpenChange || setInternalOpen; + const isOpen = controlledOpen ?? internalOpen; + const setIsOpen = onOpenChange ?? setInternalOpen; // Filter themes based on search const filteredThemes = allThemes.filter((theme) => @@ -215,7 +207,7 @@ export function ThemeSwitcher({ {!isLoading && filteredThemes.length === 0 && searchQuery && ( - No themes found matching "{searchQuery}" + No themes found matching "{searchQuery}" )} @@ -244,7 +236,7 @@ export function ThemeSwitcher({ } interface ThemeCardProps { - theme: any; + theme: ThemePreset; onSelect: () => void; previewColors: string[]; } @@ -262,7 +254,9 @@ function ThemeCard({ theme, onSelect, previewColors }: ThemeCardProps) { {theme.name} - {!theme.isBuiltIn && Custom} + {!(theme.isBuiltIn ?? false) && ( + Custom + )} {/* Color Preview */} @@ -298,7 +292,7 @@ function ImportThemeDialog({ open, onOpenChange }: ImportThemeDialogProps) { try { setError(""); - await importTheme(url.trim()); + importTheme(url.trim()); setUrl(""); onOpenChange(false); } catch (err) { diff --git a/src/contexts/theme/storage.ts b/src/contexts/theme/storage.ts index 4cdae779..e9de47e9 100644 --- a/src/contexts/theme/storage.ts +++ b/src/contexts/theme/storage.ts @@ -29,10 +29,9 @@ export function loadThemeFromStorage(): ThemeState { parsed && typeof parsed === "object" && parsed.currentMode && - parsed.cssVars && - parsed.cssVars.theme && - parsed.cssVars.light && - parsed.cssVars.dark + parsed.cssVars?.theme && + parsed.cssVars?.light && + parsed.cssVars?.dark ) { return parsed; } diff --git a/src/contexts/theme/theme-script.tsx b/src/contexts/theme/theme-script.tsx index 9b4bc9ef..80b40d86 100644 --- a/src/contexts/theme/theme-script.tsx +++ b/src/contexts/theme/theme-script.tsx @@ -1,8 +1,6 @@ "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. diff --git a/src/contexts/theme/theme-utils.ts b/src/contexts/theme/theme-utils.ts index 32ed7a12..10b51a5b 100644 --- a/src/contexts/theme/theme-utils.ts +++ b/src/contexts/theme/theme-utils.ts @@ -45,12 +45,13 @@ function normalizeThemeUrl(url: string): string { /** * Gets the theme name from the data or generates a fallback */ -function getThemeName(themeData: any): string { - return themeData.name - ? themeData.name - .replace(/[-_]/g, " ") - .replace(/\b\w/g, (l: string) => l.toUpperCase()) - : "Custom Theme"; +function getThemeName(themeData: Record): string { + if (typeof themeData.name === "string") { + return themeData.name + .replace(/[-_]/g, " ") + .replace(/\b\w/g, (l: string) => l.toUpperCase()); + } + return "Custom Theme"; } /** @@ -65,16 +66,26 @@ export async function fetchThemeFromUrl(url: string): Promise { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } - const themeData = await response.json(); + const themeData = (await response.json()) as Record; const themeName = getThemeName(themeData); const isBuiltIn = THEME_URLS.includes(url); + // Type-safe access to cssVars + const cssVarsRaw = themeData.cssVars as + | Record> + | undefined; + const cssVars = { + theme: cssVarsRaw?.theme ?? {}, + light: cssVarsRaw?.light ?? {}, + dark: cssVarsRaw?.dark ?? {}, + }; + return { name: themeName, preset: { name: themeName, isBuiltIn, - cssVars: themeData.cssVars || { theme: {}, light: {}, dark: {} }, + cssVars, }, url, type: isBuiltIn ? "built-in" : "custom", diff --git a/src/contexts/theme/use-theme-management.ts b/src/contexts/theme/use-theme-management.ts index faa1ae0e..29f34c29 100644 --- a/src/contexts/theme/use-theme-management.ts +++ b/src/contexts/theme/use-theme-management.ts @@ -17,8 +17,7 @@ import type { ThemePreset } from "./types"; * Hook for managing themes including fetching, applying, and custom imports */ export function useThemeManagement() { - const { themeState, setThemeState, toggleMode, applyTheme, resetToDefault } = - useTheme(); + const { themeState, toggleMode, applyTheme, resetToDefault } = useTheme(); const queryClient = useQueryClient(); const [customThemeUrls, setCustomThemeUrls] = useState([]); @@ -92,7 +91,7 @@ export function useThemeManagement() { } return { theme: fetchedTheme.preset, url, name: fetchedTheme.name }; }, - onSuccess: ({ theme, url, name }) => { + onSuccess: async ({ theme, url, name }) => { applyTheme(theme); // Add to custom themes if not already there @@ -101,7 +100,7 @@ export function useThemeManagement() { } // Invalidate queries to refresh the lists - queryClient.invalidateQueries({ queryKey: ["themes"] }); + await queryClient.invalidateQueries({ queryKey: ["themes"] }); toast.success(`Applied theme: ${name}`); }, @@ -132,9 +131,9 @@ export function useThemeManagement() { }; // Remove custom theme URL - const removeCustomTheme = (url: string) => { + const removeCustomTheme = async (url: string) => { setCustomThemeUrls((prev) => prev.filter((u) => u !== url)); - queryClient.invalidateQueries({ queryKey: ["themes", "custom"] }); + await queryClient.invalidateQueries({ queryKey: ["themes", "custom"] }); toast.success("Removed custom theme"); }; @@ -170,6 +169,6 @@ export function useThemeManagement() { getThemePreview, // Errors - error: builtInError || customError, + error: builtInError ?? customError, }; } From 06af58cccf952787e93a2560b55055bda6073fbf Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 18 Aug 2025 13:33:41 -0400 Subject: [PATCH 5/6] fm theme switcher from landing page nav --- src/app/(general)/_components/landing-page/navbar/index.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/(general)/_components/landing-page/navbar/index.tsx b/src/app/(general)/_components/landing-page/navbar/index.tsx index 671d4dee..e87dfde1 100644 --- a/src/app/(general)/_components/landing-page/navbar/index.tsx +++ b/src/app/(general)/_components/landing-page/navbar/index.tsx @@ -1,7 +1,6 @@ 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"; @@ -34,7 +33,6 @@ export const Navbar = () => { - From da59d1b7485ce6a7eb30ee0ca2346ec451844590 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 18 Aug 2025 13:44:10 -0400 Subject: [PATCH 6/6] edit user dropdown theme appearance --- src/app/(general)/_components/sidebar/user.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/app/(general)/_components/sidebar/user.tsx b/src/app/(general)/_components/sidebar/user.tsx index 94883a7b..c0c16bab 100644 --- a/src/app/(general)/_components/sidebar/user.tsx +++ b/src/app/(general)/_components/sidebar/user.tsx @@ -101,13 +101,14 @@ export function NavUser({ )} {currentMode === "light" ? "Light mode" : "Dark mode"} + e.preventDefault()} onClick={(e) => e.stopPropagation()} > - Appearance + Theme
{error}
Supported formats: