From ff9e8335f2621bdd4a12ac2169cd2c734b64859b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?El=C3=A9onore=20Voisin?= Date: Tue, 3 Feb 2026 14:27:31 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(front)=20add=20persistent=20darkmode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit add persistent darkmode and change settings modal --- CHANGELOG.md | 1 + .../src/cunningham/useCunninghamTheme.tsx | 75 +++++++++++++++---- .../chat/stores/useChatPreferencesStore.ts | 5 ++ .../settings/components/SettingsModal.tsx | 74 ++++++++---------- 4 files changed, 99 insertions(+), 56 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f829ab..4b2fe37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to ### Added +- ✨(front) add persistent darkmode - ✨(front) add ui kit #240 - 🧱(files) allow to use S3 storage without external access #849 - ✨(backend) add FindRagBackend #209 diff --git a/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx b/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx index 5982587..f3e2fc2 100644 --- a/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx +++ b/src/frontend/apps/conversations/src/cunningham/useCunninghamTheme.tsx @@ -2,6 +2,9 @@ import merge from 'lodash/merge'; import { create } from 'zustand'; import { persist } from 'zustand/middleware'; +import { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; +import { safeLocalStorage } from '@/utils/storages'; + import { tokens } from './cunningham-tokens'; type Tokens = typeof tokens.themes.default & @@ -26,6 +29,7 @@ interface ThemeStore { setTheme: (theme: Theme) => void; spacingsTokens: Partial; theme: Theme; + baseTheme: Theme; // 'default' or 'dsfr' (not persisted) themeTokens: Partial; isDarkMode: boolean; toggleDarkMode: () => void; @@ -49,6 +53,15 @@ const getComponentTokens = ( const DEFAULT_THEME: Theme = 'default'; const defaultTokens = getMergedTokens(DEFAULT_THEME); +// Helper to get isDarkMode from useChatPreferencesStore +const getIsDarkModeFromPreferences = (): boolean => { + try { + return useChatPreferencesStore.getState().isDarkModePreference ?? false; + } catch { + return false; + } +}; + const initialState: ThemeStore = { colorsTokens: defaultTokens.globals.colors, componentTokens: getComponentTokens(defaultTokens), @@ -58,8 +71,9 @@ const initialState: ThemeStore = { setTheme: () => {}, spacingsTokens: defaultTokens.globals.spacings, theme: DEFAULT_THEME, + baseTheme: DEFAULT_THEME, themeTokens: defaultTokens.globals, - isDarkMode: false, + isDarkMode: getIsDarkModeFromPreferences(), toggleDarkMode: () => {}, }; @@ -68,30 +82,51 @@ export const useCunninghamTheme = create()( (set) => ({ ...initialState, setTheme: (theme: Theme) => { - const newTokens = getMergedTokens(theme); + // Extract base theme (default or dsfr) + const baseTheme: Theme = + theme === 'dark' || theme === 'dsfr-dark' + ? theme === 'dark' + ? 'default' + : 'dsfr' + : theme; + + const isDarkMode = + getIsDarkModeFromPreferences() ?? + (theme === 'dark' || theme === 'dsfr-dark'); + + // Apply dark mode based on stored preference or theme + const finalTheme: Theme = isDarkMode + ? baseTheme === 'dsfr' + ? 'dsfr-dark' + : 'dark' + : baseTheme; + + const newTokens = getMergedTokens(finalTheme); set({ colorsTokens: newTokens.globals.colors, componentTokens: getComponentTokens(newTokens), contextualTokens: newTokens.contextuals, - currentTokens: tokens.themes[theme] as Partial, + currentTokens: tokens.themes[finalTheme] as Partial, fontSizesTokens: newTokens.globals.font.sizes, spacingsTokens: newTokens.globals.spacings, - theme, + theme: finalTheme, + baseTheme, themeTokens: newTokens.globals, - isDarkMode: theme === 'dark' || theme === 'dsfr-dark', + isDarkMode, }); }, toggleDarkMode: () => { + useChatPreferencesStore.getState().toggleDarkModePreferences(); + set((state) => { - const newTheme = - state.theme === 'default' - ? 'dark' - : state.theme === 'dark' - ? 'default' - : state.theme === 'dsfr' - ? 'dsfr-dark' - : 'dsfr'; + const newIsDarkMode = getIsDarkModeFromPreferences(); + const newTheme: Theme = newIsDarkMode + ? state.baseTheme === 'dsfr' + ? 'dsfr-dark' + : 'dark' + : state.baseTheme; + const newTokens = getMergedTokens(newTheme); return { @@ -102,15 +137,27 @@ export const useCunninghamTheme = create()( fontSizesTokens: newTokens.globals.font.sizes, spacingsTokens: newTokens.globals.spacings, theme: newTheme, + baseTheme: state.baseTheme, themeTokens: newTokens.globals, - isDarkMode: newTheme === 'dark' || newTheme === 'dsfr-dark', + isDarkMode: newIsDarkMode, }; }); }, }), { name: 'cunningham-theme', + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment + storage: safeLocalStorage as any, partialize: (state) => ({ isDarkMode: state.isDarkMode }), + onRehydrateStorage: () => (state, error) => { + if (error) { + console.error('[useCunninghamTheme] Rehydration error:', error); + return; + } + if (state) { + state.isDarkMode = getIsDarkModeFromPreferences(); + } + }, }, ), ); diff --git a/src/frontend/apps/conversations/src/features/chat/stores/useChatPreferencesStore.ts b/src/frontend/apps/conversations/src/features/chat/stores/useChatPreferencesStore.ts index 8efa664..094ef93 100644 --- a/src/frontend/apps/conversations/src/features/chat/stores/useChatPreferencesStore.ts +++ b/src/frontend/apps/conversations/src/features/chat/stores/useChatPreferencesStore.ts @@ -4,8 +4,10 @@ import { persist } from 'zustand/middleware'; interface ChatPreferencesState { selectedModelHrid: string | null; forceWebSearch: boolean; + isDarkModePreference: boolean; isPanelOpen: boolean; setSelectedModelHrid: (hrid: string | null) => void; + toggleDarkModePreferences: () => void; toggleForceWebSearch: () => void; setPanelOpen: (isOpen: boolean) => void; togglePanel: () => void; @@ -16,8 +18,11 @@ export const useChatPreferencesStore = create()( (set) => ({ selectedModelHrid: null, forceWebSearch: false, + isDarkModePreference: false, isPanelOpen: false, setSelectedModelHrid: (hrid) => set({ selectedModelHrid: hrid }), + toggleDarkModePreferences: () => + set((state) => ({ isDarkModePreference: !state.isDarkModePreference })), toggleForceWebSearch: () => set((state) => ({ forceWebSearch: !state.forceWebSearch })), setPanelOpen: (isOpen) => set({ isPanelOpen: isOpen }), diff --git a/src/frontend/apps/conversations/src/features/settings/components/SettingsModal.tsx b/src/frontend/apps/conversations/src/features/settings/components/SettingsModal.tsx index c9c5b2a..b352712 100644 --- a/src/frontend/apps/conversations/src/features/settings/components/SettingsModal.tsx +++ b/src/frontend/apps/conversations/src/features/settings/components/SettingsModal.tsx @@ -2,7 +2,6 @@ import { Modal, ModalSize } from '@openfun/cunningham-react'; import { useTranslation } from 'react-i18next'; import { Box, StyledLink, Text, ToggleSwitch, useToast } from '@/components'; -import { HorizontalSeparator } from '@/components/separators/HorizontalSeparator'; import { useUserUpdate } from '@/core/api/useUserUpdate'; import { useCunninghamTheme } from '@/cunningham'; import { useAuthQuery } from '@/features/auth/api'; @@ -56,7 +55,7 @@ export const SettingsModal = ({ onClose, isOpen }: SettingsModalProps) => { title={t('Assistant settings')} > - + { $padding={{ top: 'sm', bottom: 'sm' }} > {t( - 'The Assistant is a sovereign conversational AI designed for public servants. It helps you save time on daily tasks like rephrasing, summarising, translating, or searching information. Your data never leaves France and is stored on secure, state-compliant infrastructures. It is never used for commercial purposes.', + 'The Assistant is a sovereign AI for public servants. It helps with daily tasks (rephrasing, summarising, translating, information search). Your data stays in France on secure, state-compliant infrastructure and is never used for commercial purposes.', )} - - - {t('Dark mode')} - - - - - {t( - 'Enable dark mode to reduce eye strain in low-light environments.', - )} - - - toggleDarkMode()} - aria-label={t('Dark mode')} - /> - - - - - { aria-label={t('Allow conversation analysis')} /> + + + + + {t('Dark mode')} + + + toggleDarkMode()} + aria-label={t('Dark mode')} + /> + +