Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: User setting to select preferred theme for the user - dark, light, system #1032

Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
"jotai": "^2.9.3",
"js-cookie": "^3.0.5",
"lowlight": "^3.1.0",
"next-themes": "^0.3.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-dropzone": "^14.2.3",
Expand Down Expand Up @@ -71,4 +72,4 @@
"@types/turndown": "^5.0.4",
"typescript": "^5.3.3"
}
}
}
25 changes: 8 additions & 17 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,9 @@ import { ChannelRedirect } from './utils/channel/ChannelRedirect'
import "cal-sans";
import { ThemeProvider } from './ThemeProvider'
import { Toaster } from 'sonner'
import { useStickyState } from './hooks/useStickyState'
import MobileTabsPage from './pages/MobileTabsPage'
import { UserProfile } from './components/feature/userSettings/UserProfile/UserProfile'

import { Appearance } from './components/feature/userSettings/Appearance/Appearance'

const router = createBrowserRouter(
createRoutesFromElements(
Expand All @@ -27,6 +26,7 @@ const router = createBrowserRouter(
<Route path="settings" lazy={() => import('./pages/settings/Settings')}>
<Route index element={<UserProfile />} />
<Route path="profile" element={<UserProfile />} />
<Route path="appearance" element={<Appearance />} />
<Route path="users" lazy={() => import('./components/feature/userSettings/Users/AddUsers')} />
<Route path="frappe-hr" lazy={() => import('./pages/settings/Integrations/FrappeHR')} />
{/* <Route path="bots" lazy={() => import('./components/feature/userSettings/Bots')} /> */}
Expand All @@ -52,14 +52,7 @@ const router = createBrowserRouter(
)
function App() {

const [appearance, setAppearance] = useStickyState<'light' | 'dark'>('dark', 'appearance');

const toggleTheme = () => {
setAppearance(appearance === 'dark' ? 'light' : 'dark');
};

// We need to pass sitename only if the Frappe version is v15 or above.

const getSiteName = () => {
// @ts-ignore
if (window.frappe?.boot?.versions?.frappe && (window.frappe.boot.versions.frappe.startsWith('15') || window.frappe.boot.versions.frappe.startsWith('16'))) {
Expand All @@ -82,14 +75,12 @@ function App() {
>
<UserProvider>
<Toaster richColors />
<ThemeProvider
appearance={appearance}
// grayColor='slate'
accentColor='iris'
panelBackground='translucent'
toggleTheme={toggleTheme}>
<RouterProvider router={router} />
</ThemeProvider>
<ThemeProvider
// grayColor='slate'
accentColor='iris'
panelBackground='translucent'>
<RouterProvider router={router} />
</ThemeProvider>
</UserProvider>
</FrappeProvider>
)
Expand Down
100 changes: 66 additions & 34 deletions frontend/src/ThemeProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,54 +1,86 @@
/* eslint-disable react/jsx-no-useless-fragment */
import { Theme } from '@radix-ui/themes';
import { Theme, ThemeProps } from '@radix-ui/themes';
import React from 'react';
import { ThemeProps } from '@radix-ui/themes/dist/cjs/theme';
import { PropsWithChildren, useEffect } from 'react';
import { PropsWithChildren, useEffect, useCallback, useState, useMemo } from 'react';
import { useStickyState } from '@/hooks/useStickyState';
import { toast } from 'sonner'

const MEDIA = '(prefers-color-scheme: dark)'

export type ThemeType = 'light' | 'dark' | 'system';
interface ThemeProviderProps extends ThemeProps {
toggleTheme: () => void;
defaultTheme?: ThemeType
}
export const ThemeProvider: React.FC<PropsWithChildren<ThemeProviderProps>> = ({ children, toggleTheme, ...props }) => {

export const ThemeProvider: React.FC<PropsWithChildren<ThemeProviderProps>> = ({ children, defaultTheme = 'system' }) => {
const [appearance, setAppearance] = useStickyState<ThemeType>(defaultTheme, 'app-theme');
const [effectiveTheme, setEffectiveTheme] = useState<ThemeType>(appearance);

const handleMediaQuery = useCallback((e: MediaQueryListEvent | MediaQueryList) => {
const resolvedTheme = systemTheme();
setEffectiveTheme(resolvedTheme);

setThemeDataAttribute(resolvedTheme);
setMetaThemeColor(resolvedTheme);

toast.success(`Theme switched to ${appearance === 'system' ? `${appearance}(${resolvedTheme})` : appearance}`)
}, [appearance])

useEffect(() => {
const media = window.matchMedia(MEDIA)
media.addEventListener("change", handleMediaQuery)
return () => media.removeEventListener("change", handleMediaQuery)
}, [handleMediaQuery])

useEffect(() => {
const metaThemeColor = document.querySelector("meta[name=theme-color]");
switch (props.appearance) {
case 'light': {
if (document?.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add('light');
pranavmene2000 marked this conversation as resolved.
Show resolved Hide resolved
metaThemeColor?.setAttribute('content', '#FFFFFF');

}

break;
}
case 'dark': {
if (document?.body) {
document.body.classList.remove('light', 'dark');
document.body.classList.add('dark');
pranavmene2000 marked this conversation as resolved.
Show resolved Hide resolved
metaThemeColor?.setAttribute('content', '#191919');
}

break;
}
}
}, [props.appearance]);
setMetaThemeColor(appearance);
setThemeDataAttribute(appearance);
}, [appearance]);

const handleThemeChange = (newTheme: ThemeType) => {
setAppearance(newTheme);
toast.success(`Theme switched to ${newTheme === 'system' ? `${newTheme}(${systemTheme()})` : newTheme}`)
};

return (
<Theme {...props}>
<ThemeContext.Provider value={{ appearance: props.appearance as 'light' | 'dark', toggleTheme }}>
<Theme appearance={appearance === 'system' ? effectiveTheme !== 'system' ? effectiveTheme : systemTheme() : appearance}>
<ThemeContext.Provider value={{ appearance: appearance as ThemeType, changeTheme: handleThemeChange, systemTheme }}>
{children}
</ThemeContext.Provider>
</Theme>
);
};

interface ThemeContextType {
appearance: 'light' | 'dark';
toggleTheme: () => void;
appearance: ThemeType;
changeTheme: (newTheme: ThemeType) => void;
systemTheme: () => Omit<ThemeType, 'system'>;
}

export const ThemeContext = React.createContext<ThemeContextType>({
appearance: 'dark',
toggleTheme: () => { },
appearance: 'system',
changeTheme: () => {},
systemTheme: () => 'light'
});

export const useTheme = () => React.useContext(ThemeContext);

const systemTheme = (e?: MediaQueryListEvent | MediaQueryList) => {
if (!e) e = window.matchMedia(MEDIA);
return e.matches ? 'dark' : 'light';
}

const setThemeDataAttribute = (appearance: ThemeType) => {
if (appearance === 'system') {
document.documentElement.setAttribute('data-theme', systemTheme());
return;
}
document.documentElement.setAttribute('data-theme', appearance);
}

const setMetaThemeColor = (appearance: ThemeType) => {
const metaThemeColor = document.querySelector("meta[name=theme-color]");
if (appearance === 'system') if (document?.body) metaThemeColor?.setAttribute('content', systemTheme() === 'light' ? '#FFFFFF' : '#191919');
if (appearance === 'light') if (document?.body) metaThemeColor?.setAttribute('content', '#FFFFFF');
if (appearance === 'dark') if (document?.body) metaThemeColor?.setAttribute('content', '#191919');
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { useState } from "react"
import { Box, Button, Flex, Text, Card, RadioCards } from "@radix-ui/themes"
import { clsx } from 'clsx'
import { ThemeType, useTheme } from "@/ThemeProvider";

export const Appearance = () => {
const { appearance, changeTheme } = useTheme();
const [localAppearance, setLocalAppearance] = useState<ThemeType>(appearance as ThemeType);

const handleLocalApperanceChange = (value: ThemeType) => {
setLocalAppearance(value);
}

const saveTheme = () => {
changeTheme(localAppearance)
}

return (
<Flex direction='column' gap='4' px='6' py='4'>
<Flex direction={'column'} gap='4'>
<Flex justify={'between'} align={'center'}>
<Flex direction='column' gap='0'>
<Text size='3' className={'font-semibold'}>Appearance</Text>
<Text size='1' color='gray'>Manage your Raven appearance</Text>
</Flex>
<Button onClick={saveTheme} type='submit' disabled={localAppearance === appearance}>
Save
</Button>
</Flex>

<Card className="p-0">
<Box className="flex justify-center items-center bg-slate-2 dark:bg-slate-3 py-10 px-6">
<RadioCards.Root onValueChange={handleLocalApperanceChange} defaultValue={localAppearance || appearance} columns={{ initial: '1', sm: '1', md: '2', lg: '3' }} gap="6" size="3">
<RadioItem value="system" localAppearance={localAppearance} label="System default" imgURL="https://app.cal.com/theme-system.svg" />
<RadioItem value="light" localAppearance={localAppearance} label="Light" imgURL="https://app.cal.com/theme-light.svg" />
<RadioItem value="dark" localAppearance={localAppearance} label="Dark" imgURL="https://app.cal.com/theme-dark.svg" />
</RadioCards.Root>
</Box>
</Card>
</Flex>
</Flex>
)
}


interface RadioItemProps {
value: string,
localAppearance: string,
label: string,
imgURL: string
}

export const RadioItem = ({ value, localAppearance, label, imgURL }: RadioItemProps) => {
return (
<Flex direction="column" align="center" className={clsx("transition-transform duration-300", value === localAppearance ? 'scale-110' : '')}>
<RadioCards.Item className="p-0 cursor-pointer" value={value}>
<img src={imgURL} className="w-full h-auto" />
</RadioCards.Item>
<Text className="mt-2 text-[14px]" weight='medium' as="p">{label}</Text>
</Flex>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export const SettingsSidebar = () => {
<Flex direction="column" gap='2' className='px-4'>
<SettingsGroup title="My Account" icon={LuUserCircle2}>
<SettingsSidebarItem title="Profile" to='profile' />
<SettingsSidebarItem title="Appearance" to='appearance' />
{/* <SettingsSidebarItem title="Preferences" to='preferences' /> */}
</SettingsGroup>
<SettingsSeparator />
Expand Down
22 changes: 1 addition & 21 deletions frontend/src/components/layout/Sidebar/SidebarHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { useTheme } from '@/ThemeProvider'
import { commandMenuOpenAtom } from '@/components/feature/CommandMenu/CommandMenu'
import { Flex, IconButton, Text } from '@radix-ui/themes'
import { BiCommand, BiMoon, BiSun } from 'react-icons/bi'
import { BiCommand } from 'react-icons/bi'
import { useSetAtom } from 'jotai'

export const SidebarHeader = () => {
Expand All @@ -16,7 +15,6 @@ export const SidebarHeader = () => {
<Text as='span' size='6' className='cal-sans pl-1'>raven</Text>
<Flex align='center' gap='4' className='pr-1 sm:pr-0'>
<SearchButton />
<ColorModeToggleButton />
</Flex>
</Flex>
</header>
Expand All @@ -40,22 +38,4 @@ const SearchButton = () => {
<BiCommand className='text-lg' />
</IconButton>
)
}

const ColorModeToggleButton = () => {

const { appearance, toggleTheme } = useTheme()

return <Flex align='center' justify='center' pr='1'>
<IconButton
size={{ initial: '2', md: '1' }}
aria-label='Toggle theme'
title='Toggle theme'
color='gray'
className='text-gray-11 sm:hover:text-gray-12'
variant='ghost'
onClick={toggleTheme}>
{appearance === 'light' ? <BiMoon className='text-lg sm:text-base' /> : <BiSun className='text-lg sm:text-base' />}
</IconButton>
</Flex>
}
Loading
Loading