diff --git a/frontend/src/app/AppLayout.jsx b/frontend/src/app/AppLayout.jsx index 062ce6d..98fc74c 100644 --- a/frontend/src/app/AppLayout.jsx +++ b/frontend/src/app/AppLayout.jsx @@ -28,7 +28,6 @@ const DEFAULT_FOLDER_NAME = 'Documents'; const ROW_KEY_SEPARATOR = ':'; const DOCUMENT_ROW_PREFIX = 'document'; const FOLDER_ROW_PREFIX = 'folder'; -const THEME_STORAGE_KEY = 'papercrate_theme_settings'; const resolveApiPath = (path = '') => path; @@ -199,66 +198,6 @@ const AppLayout = () => { }); const lastNonDeskViewRef = useRef(documentsViewMode === 'desk' ? 'list' : documentsViewMode); - const loadThemeSettings = () => { - const defaults = { neutralHue: 260, mode: 'system' }; - if (typeof window === 'undefined') { - if (typeof document !== 'undefined') { - const current = document.documentElement.style.getPropertyValue('--neutral-hue'); - const parsed = Number.parseInt(current, 10); - return { - neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, - mode: defaults.mode, - }; - } - return defaults; - } - - const root = document.documentElement; - const loadFromRoot = () => { - const current = root.style.getPropertyValue('--neutral-hue'); - const parsed = Number.parseInt(current, 10); - return Number.isNaN(parsed) ? defaults.neutralHue : parsed; - }; - - let neutralHueValue = loadFromRoot(); - let modeValue = defaults.mode; - - const composite = window.localStorage.getItem(THEME_STORAGE_KEY); - if (composite) { - try { - const parsed = JSON.parse(composite); - const storedHue = Number.parseInt(parsed?.neutralHue, 10); - if (!Number.isNaN(storedHue)) { - neutralHueValue = storedHue; - } - const storedMode = parsed?.mode; - if (storedMode === 'light' || storedMode === 'dark' || storedMode === 'system') { - modeValue = storedMode; - } - } catch (error) { - console.warn('[theme] failed to parse stored theme settings', error); - } - } else { - const legacyHue = window.localStorage.getItem('papercrate_neutral_hue'); - if (legacyHue) { - const parsedLegacyHue = Number.parseInt(legacyHue, 10); - if (!Number.isNaN(parsedLegacyHue)) { - neutralHueValue = parsedLegacyHue; - } - } - const legacyMode = window.localStorage.getItem('papercrate_theme_mode'); - if (legacyMode === 'light' || legacyMode === 'dark' || legacyMode === 'system') { - modeValue = legacyMode; - } - } - - return { neutralHue: neutralHueValue, mode: modeValue }; - }; - - const initialThemeSettings = loadThemeSettings(); - const [neutralHue, setNeutralHue] = useState(initialThemeSettings.neutralHue); - const [themeMode, setThemeMode] = useState(initialThemeSettings.mode); - useEffect(() => { if (documentsViewMode !== 'desk') { lastNonDeskViewRef.current = documentsViewMode; @@ -363,56 +302,6 @@ const AppLayout = () => { } }, [token, appStatus, refreshAccessToken]); - useEffect(() => { - if (typeof document !== 'undefined') { - document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); - } - }, [neutralHue]); - - useEffect(() => { - if (typeof document !== 'undefined') { - const root = document.documentElement; - if (themeMode === 'system') { - root.removeAttribute('data-theme'); - } else { - root.setAttribute('data-theme', themeMode); - } - } - }, [themeMode]); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - try { - const payload = JSON.stringify({ neutralHue, mode: themeMode }); - window.localStorage.setItem(THEME_STORAGE_KEY, payload); - window.localStorage.removeItem('papercrate_neutral_hue'); - window.localStorage.removeItem('papercrate_theme_mode'); - } catch (error) { - console.warn('[theme] failed to persist theme settings', error); - } - }, [neutralHue, themeMode]); - - const handleNeutralHueChange = useCallback((value) => { - if (value === '') { - setNeutralHue(180); - return; - } - const parsed = Number(value); - if (Number.isNaN(parsed)) { - return; - } - setNeutralHue(parsed); - }, []); - - const handleThemeModeChange = useCallback((mode) => { - if (mode !== 'light' && mode !== 'dark' && mode !== 'system') { - return; - } - setThemeMode(mode); - }, []); - const clearFilters = useCallback(() => { setSearchQuery(''); setActiveTagFilters([]); @@ -5084,10 +4973,6 @@ const AppLayout = () => { draggedFolderId, onCreateFolder: handlePromptCreateFolder, creatingFolder, - onNeutralHueChange: handleNeutralHueChange, - neutralHue, - themeMode, - onThemeModeChange: handleThemeModeChange, tags, activeTagIds: activeTagFilters, onToggleTagFilter: toggleTagFilter, @@ -5146,10 +5031,6 @@ const AppLayout = () => { handleCorrespondentCreate, handlePromptCreateFolder, creatingFolder, - handleNeutralHueChange, - neutralHue, - themeMode, - handleThemeModeChange, ], ); diff --git a/frontend/src/sidebar/Sidebar.jsx b/frontend/src/sidebar/Sidebar.jsx index 212535d..c344e2c 100644 --- a/frontend/src/sidebar/Sidebar.jsx +++ b/frontend/src/sidebar/Sidebar.jsx @@ -157,10 +157,6 @@ const Sidebar = ({ draggedFolderId, onCreateFolder, creatingFolder = false, - onNeutralHueChange, - neutralHue, - themeMode = 'system', - onThemeModeChange, tags = [], activeTagIds = [], onToggleTagFilter, @@ -184,7 +180,15 @@ const Sidebar = ({ onSelectTenant, onOpenSettings, }) => { - const { setCollapsed } = useSidebarContext(); + const { + setCollapsed, + neutralHue, + setNeutralHue, + resetNeutralHue, + themeMode, + cycleThemeMode, + themeModes, + } = useSidebarContext(); const handleCollapse = useCallback(() => { setCollapsed(true); }, [setCollapsed]); @@ -246,15 +250,31 @@ const Sidebar = ({ }, [creatingFolder, onCreateFolder]); const handleNeutralHueReset = useCallback(() => { - onNeutralHueChange?.(''); - }, [onNeutralHueChange]); + resetNeutralHue(); + }, [resetNeutralHue]); - const themeModeIndex = THEME_MODES.indexOf(themeMode); + const handleNeutralHueChange = useCallback( + (value) => { + if (value === '') { + resetNeutralHue(); + return; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return; + } + setNeutralHue(parsed); + }, + [resetNeutralHue, setNeutralHue], + ); + + const themeModeList = themeModes && themeModes.length ? themeModes : THEME_MODES; + const themeModeIndex = themeModeList.indexOf(themeMode); const safeThemeModeIndex = themeModeIndex === -1 ? 0 : themeModeIndex; - const resolvedThemeMode = THEME_MODES[safeThemeModeIndex]; - const nextThemeMode = THEME_MODES[(safeThemeModeIndex + 1) % THEME_MODES.length]; - const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode]; - const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode]; + const resolvedThemeMode = themeModeList[safeThemeModeIndex] || THEME_MODES[0]; + const nextThemeMode = themeModeList[(safeThemeModeIndex + 1) % themeModeList.length]; + const themeModeLabel = THEME_MODE_LABELS[resolvedThemeMode] || THEME_MODE_LABELS.system; + const nextThemeLabel = THEME_MODE_LABELS[nextThemeMode] || THEME_MODE_LABELS.system; const themeModeIcon = resolvedThemeMode === 'dark' ? : resolvedThemeMode === 'light' @@ -262,14 +282,8 @@ const Sidebar = ({ : ; const handleThemeModeToggle = useCallback(() => { - if (!onThemeModeChange) { - return; - } - const currentIndex = THEME_MODES.indexOf(themeMode); - const safeIndex = currentIndex === -1 ? 0 : currentIndex; - const nextMode = THEME_MODES[(safeIndex + 1) % THEME_MODES.length]; - onThemeModeChange(nextMode); - }, [onThemeModeChange, themeMode]); + cycleThemeMode(); + }, [cycleThemeMode]); const handleSearchInputChange = useCallback( (event) => { @@ -415,7 +429,7 @@ const Sidebar = ({ max="360" step="1" value={neutralHue} - onChange={(event) => onNeutralHueChange?.(event.target.value)} + onChange={(event) => handleNeutralHueChange(event.target.value)} /> {neutralHue}° diff --git a/frontend/src/sidebar/SidebarContext.js b/frontend/src/sidebar/SidebarContext.js index cfbe5e5..a609db3 100644 --- a/frontend/src/sidebar/SidebarContext.js +++ b/frontend/src/sidebar/SidebarContext.js @@ -1,11 +1,161 @@ -import React, { createContext, useContext, useMemo, useState, useCallback } from 'react'; +import React, { + createContext, + useContext, + useMemo, + useState, + useCallback, + useEffect, +} from 'react'; const SidebarContext = createContext(null); +const THEME_STORAGE_KEY = 'papercrate_theme_settings'; +const DEFAULT_NEUTRAL_HUE = 180; +const DEFAULT_THEME_MODE = 'system'; +const THEME_MODES = ['system', 'light', 'dark']; + +const loadInitialThemeSettings = () => { + const defaults = { neutralHue: DEFAULT_NEUTRAL_HUE, mode: DEFAULT_THEME_MODE }; + + if (typeof window === 'undefined') { + if (typeof document !== 'undefined') { + const current = document.documentElement.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return { + neutralHue: Number.isNaN(parsed) ? defaults.neutralHue : parsed, + mode: defaults.mode, + }; + } + return defaults; + } + + const root = document.documentElement; + const loadFromRoot = () => { + const current = root.style.getPropertyValue('--neutral-hue'); + const parsed = Number.parseInt(current, 10); + return Number.isNaN(parsed) ? defaults.neutralHue : parsed; + }; + + let neutralHueValue = loadFromRoot(); + let modeValue = defaults.mode; + + const composite = window.localStorage.getItem(THEME_STORAGE_KEY); + if (composite) { + try { + const parsed = JSON.parse(composite); + const storedHue = Number.parseInt(parsed?.neutralHue, 10); + if (!Number.isNaN(storedHue)) { + neutralHueValue = storedHue; + } + const storedMode = parsed?.mode; + if (THEME_MODES.includes(storedMode)) { + modeValue = storedMode; + } + } catch (error) { + console.warn('[theme] failed to parse stored theme settings', error); + } + } else { + const legacyHue = window.localStorage.getItem('papercrate_neutral_hue'); + if (legacyHue) { + const parsedLegacyHue = Number.parseInt(legacyHue, 10); + if (!Number.isNaN(parsedLegacyHue)) { + neutralHueValue = parsedLegacyHue; + } + } + const legacyMode = window.localStorage.getItem('papercrate_theme_mode'); + if (THEME_MODES.includes(legacyMode)) { + modeValue = legacyMode; + } + } + + return { neutralHue: neutralHueValue, mode: modeValue }; +}; + export const SidebarProvider = ({ initialCollapsed = false, children }) => { const [collapsed, setCollapsed] = useState(initialCollapsed); + const initialTheme = useMemo(() => loadInitialThemeSettings(), []); + const [neutralHue, setNeutralHueState] = useState(initialTheme.neutralHue); + const [themeMode, setThemeModeState] = useState(initialTheme.mode); - const contextValue = useMemo(() => ({ collapsed, setCollapsed }), [collapsed]); + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + document.documentElement.style.setProperty('--neutral-hue', `${neutralHue}deg`); + }, [neutralHue]); + + useEffect(() => { + if (typeof document === 'undefined') { + return; + } + const root = document.documentElement; + if (themeMode === 'system') { + root.removeAttribute('data-theme'); + } else { + root.setAttribute('data-theme', themeMode); + } + }, [themeMode]); + + useEffect(() => { + if (typeof window === 'undefined') { + return; + } + try { + const payload = JSON.stringify({ neutralHue, mode: themeMode }); + window.localStorage.setItem(THEME_STORAGE_KEY, payload); + window.localStorage.removeItem('papercrate_neutral_hue'); + window.localStorage.removeItem('papercrate_theme_mode'); + } catch (error) { + console.warn('[theme] failed to persist theme settings', error); + } + }, [neutralHue, themeMode]); + + const setNeutralHue = useCallback((value) => { + setNeutralHueState((prev) => { + if (value === '' || value === null || typeof value === 'undefined') { + return DEFAULT_NEUTRAL_HUE; + } + const parsed = Number(value); + if (Number.isNaN(parsed)) { + return prev; + } + const clamped = Math.min(Math.max(Math.round(parsed), 0), 360); + return clamped; + }); + }, []); + + const resetNeutralHue = useCallback(() => { + setNeutralHueState(DEFAULT_NEUTRAL_HUE); + }, []); + + const setThemeMode = useCallback((mode) => { + if (!THEME_MODES.includes(mode)) { + return; + } + setThemeModeState(mode); + }, []); + + const cycleThemeMode = useCallback(() => { + const index = THEME_MODES.indexOf(themeMode); + const nextIndex = index === -1 ? 0 : (index + 1) % THEME_MODES.length; + setThemeModeState(THEME_MODES[nextIndex]); + }, [themeMode]); + + const contextValue = useMemo( + () => ({ + collapsed, + setCollapsed, + neutralHue, + setNeutralHue, + resetNeutralHue, + themeMode, + setThemeMode, + cycleThemeMode, + themeModes: THEME_MODES, + defaultNeutralHue: DEFAULT_NEUTRAL_HUE, + }), + [collapsed, neutralHue, setNeutralHue, resetNeutralHue, themeMode, setThemeMode, cycleThemeMode], + ); return {children}; };