From 4389f50e7d45dc64004b86ea95736002beba3bc0 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Thu, 22 Jan 2026 01:58:24 +0000 Subject: [PATCH] feat: implement 4-theme system with Zustand persistence Co-Authored-By: Claude Opus 4.5 --- apps/web/app/layout.tsx | 7 ++-- .../components/providers/theme-provider.tsx | 26 ++++++++++++++ apps/web/lib/utils.ts | 6 ++++ apps/web/stores/theme-store.ts | 20 +++++++++++ apps/web/themes/corporate.ts | 32 +++++++++++++++++ apps/web/themes/dark.ts | 36 +++++++++++++++++++ apps/web/themes/index.ts | 16 +++++++++ apps/web/themes/light.ts | 31 ++++++++++++++++ apps/web/themes/vibrant.ts | 31 ++++++++++++++++ 9 files changed, 203 insertions(+), 2 deletions(-) create mode 100644 apps/web/components/providers/theme-provider.tsx create mode 100644 apps/web/lib/utils.ts create mode 100644 apps/web/stores/theme-store.ts create mode 100644 apps/web/themes/corporate.ts create mode 100644 apps/web/themes/dark.ts create mode 100644 apps/web/themes/index.ts create mode 100644 apps/web/themes/light.ts create mode 100644 apps/web/themes/vibrant.ts diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 30bd37a..7d86181 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; +import { ThemeProvider } from '@/components/providers/theme-provider'; const inter = Inter({ subsets: ['latin'] }); @@ -15,8 +16,10 @@ export default function RootLayout({ children: React.ReactNode; }) { return ( - - {children} + + + {children} + ); } diff --git a/apps/web/components/providers/theme-provider.tsx b/apps/web/components/providers/theme-provider.tsx new file mode 100644 index 0000000..b685368 --- /dev/null +++ b/apps/web/components/providers/theme-provider.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useEffect } from 'react'; +import { useThemeStore } from '@/stores/theme-store'; +import { themes } from '@/themes'; + +export function ThemeProvider({ children }: { children: React.ReactNode }) { + const { theme } = useThemeStore(); + + useEffect(() => { + const selectedTheme = themes[theme]; + const root = document.documentElement; + + Object.entries(selectedTheme.cssVars).forEach(([key, value]) => { + root.style.setProperty(key, value); + }); + + if (theme === 'dark') { + root.classList.add('dark'); + } else { + root.classList.remove('dark'); + } + }, [theme]); + + return <>{children}; +} diff --git a/apps/web/lib/utils.ts b/apps/web/lib/utils.ts new file mode 100644 index 0000000..9ad0df4 --- /dev/null +++ b/apps/web/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx'; +import { twMerge } from 'tailwind-merge'; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/apps/web/stores/theme-store.ts b/apps/web/stores/theme-store.ts new file mode 100644 index 0000000..3018e00 --- /dev/null +++ b/apps/web/stores/theme-store.ts @@ -0,0 +1,20 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { ThemeName } from '@/themes'; + +interface ThemeState { + theme: ThemeName; + setTheme: (theme: ThemeName) => void; +} + +export const useThemeStore = create()( + persist( + (set) => ({ + theme: 'light', + setTheme: (theme) => set({ theme }), + }), + { + name: 'horux-theme', + } + ) +); diff --git a/apps/web/themes/corporate.ts b/apps/web/themes/corporate.ts new file mode 100644 index 0000000..2c5e398 --- /dev/null +++ b/apps/web/themes/corporate.ts @@ -0,0 +1,32 @@ +export const corporateTheme = { + name: 'corporate' as const, + label: 'Corporate', + layout: 'multi-panel', + cssVars: { + '--background': '210 20% 96%', + '--foreground': '210 50% 10%', + '--card': '0 0% 100%', + '--card-foreground': '210 50% 10%', + '--primary': '210 100% 25%', + '--primary-foreground': '0 0% 100%', + '--secondary': '210 20% 92%', + '--secondary-foreground': '210 50% 10%', + '--muted': '210 20% 92%', + '--muted-foreground': '210 15% 45%', + '--accent': '43 96% 56%', + '--accent-foreground': '210 50% 10%', + '--destructive': '0 84.2% 60.2%', + '--destructive-foreground': '210 40% 98%', + '--success': '142.1 76.2% 36.3%', + '--success-foreground': '355.7 100% 97.3%', + '--border': '210 20% 85%', + '--input': '210 20% 85%', + '--ring': '210 100% 25%', + '--radius': '0.25rem', + }, + sidebar: { + width: '200px', + collapsible: false, + }, + density: 'compact', +}; diff --git a/apps/web/themes/dark.ts b/apps/web/themes/dark.ts new file mode 100644 index 0000000..90326d7 --- /dev/null +++ b/apps/web/themes/dark.ts @@ -0,0 +1,36 @@ +export const darkTheme = { + name: 'dark' as const, + label: 'Dark', + layout: 'minimal-floating', + cssVars: { + '--background': '0 0% 3.9%', + '--foreground': '0 0% 98%', + '--card': '0 0% 6%', + '--card-foreground': '0 0% 98%', + '--primary': '187.2 85.7% 53.3%', + '--primary-foreground': '0 0% 3.9%', + '--secondary': '0 0% 12%', + '--secondary-foreground': '0 0% 98%', + '--muted': '0 0% 12%', + '--muted-foreground': '0 0% 63.9%', + '--accent': '142.1 70.6% 45.3%', + '--accent-foreground': '0 0% 3.9%', + '--destructive': '0 62.8% 30.6%', + '--destructive-foreground': '0 0% 98%', + '--success': '142.1 70.6% 45.3%', + '--success-foreground': '144.9 80.4% 10%', + '--border': '0 0% 14.9%', + '--input': '0 0% 14.9%', + '--ring': '187.2 85.7% 53.3%', + '--radius': '0.75rem', + }, + sidebar: { + width: '64px', + collapsible: false, + iconsOnly: true, + }, + effects: { + blur: '10px', + glow: '0 0 20px rgba(34,211,238,0.3)', + }, +}; diff --git a/apps/web/themes/index.ts b/apps/web/themes/index.ts new file mode 100644 index 0000000..6bf60e4 --- /dev/null +++ b/apps/web/themes/index.ts @@ -0,0 +1,16 @@ +import { lightTheme } from './light'; +import { vibrantTheme } from './vibrant'; +import { corporateTheme } from './corporate'; +import { darkTheme } from './dark'; + +export const themes = { + light: lightTheme, + vibrant: vibrantTheme, + corporate: corporateTheme, + dark: darkTheme, +} as const; + +export type ThemeName = keyof typeof themes; +export type Theme = (typeof themes)[ThemeName]; + +export { lightTheme, vibrantTheme, corporateTheme, darkTheme }; diff --git a/apps/web/themes/light.ts b/apps/web/themes/light.ts new file mode 100644 index 0000000..c8014e3 --- /dev/null +++ b/apps/web/themes/light.ts @@ -0,0 +1,31 @@ +export const lightTheme = { + name: 'light' as const, + label: 'Light', + layout: 'sidebar-fixed', + cssVars: { + '--background': '0 0% 100%', + '--foreground': '222.2 84% 4.9%', + '--card': '0 0% 100%', + '--card-foreground': '222.2 84% 4.9%', + '--primary': '221.2 83.2% 53.3%', + '--primary-foreground': '210 40% 98%', + '--secondary': '210 40% 96.1%', + '--secondary-foreground': '222.2 47.4% 11.2%', + '--muted': '210 40% 96.1%', + '--muted-foreground': '215.4 16.3% 46.9%', + '--accent': '210 40% 96.1%', + '--accent-foreground': '222.2 47.4% 11.2%', + '--destructive': '0 84.2% 60.2%', + '--destructive-foreground': '210 40% 98%', + '--success': '142.1 76.2% 36.3%', + '--success-foreground': '355.7 100% 97.3%', + '--border': '214.3 31.8% 91.4%', + '--input': '214.3 31.8% 91.4%', + '--ring': '221.2 83.2% 53.3%', + '--radius': '0.5rem', + }, + sidebar: { + width: '240px', + collapsible: false, + }, +}; diff --git a/apps/web/themes/vibrant.ts b/apps/web/themes/vibrant.ts new file mode 100644 index 0000000..4fb2441 --- /dev/null +++ b/apps/web/themes/vibrant.ts @@ -0,0 +1,31 @@ +export const vibrantTheme = { + name: 'vibrant' as const, + label: 'Vibrant', + layout: 'sidebar-collapsible', + cssVars: { + '--background': '270 50% 98%', + '--foreground': '263.4 84% 6.7%', + '--card': '0 0% 100%', + '--card-foreground': '263.4 84% 6.7%', + '--primary': '262.1 83.3% 57.8%', + '--primary-foreground': '210 40% 98%', + '--secondary': '187 85.7% 53.3%', + '--secondary-foreground': '222.2 47.4% 11.2%', + '--muted': '270 30% 94%', + '--muted-foreground': '263.4 25% 40%', + '--accent': '24.6 95% 53.1%', + '--accent-foreground': '0 0% 100%', + '--destructive': '0 84.2% 60.2%', + '--destructive-foreground': '210 40% 98%', + '--success': '142.1 76.2% 36.3%', + '--success-foreground': '355.7 100% 97.3%', + '--border': '270 30% 88%', + '--input': '270 30% 88%', + '--ring': '262.1 83.3% 57.8%', + '--radius': '1rem', + }, + sidebar: { + width: '280px', + collapsible: true, + }, +};