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};
};