From 28e45917a256b81ed5f44711088d30eaee0aa52d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 15 Oct 2025 11:53:00 +0100 Subject: [PATCH 1/2] Refactor user preferences (#4667) # Description of Changes Refactor user preferences to all be in one service and all stored in localStorage instead of indexeddb. This allows simpler & quicker accessing of them, and ensures that they're all neatly stored in one consistent place instead of spread out over local storage. --- frontend/src/App.tsx | 12 +- .../shared/RainbowThemeProvider.tsx | 3 +- .../config/configSections/GeneralSection.tsx | 2 +- frontend/src/components/tools/ToolPanel.tsx | 9 +- .../components/tools/ToolPanelModePrompt.tsx | 20 +-- frontend/src/constants/theme.ts | 8 + frontend/src/constants/toolPanel.ts | 5 + frontend/src/contexts/PreferencesContext.tsx | 51 ++---- frontend/src/contexts/ToolWorkflowContext.tsx | 24 +-- .../toolWorkflow/toolWorkflowState.ts | 22 +-- .../hooks/tools/useJsonLocalStorageState.ts | 30 ---- frontend/src/hooks/useRainbowTheme.ts | 58 +++---- .../src/hooks/useToolPanelModePreference.ts | 50 ------ frontend/src/index.tsx | 14 +- frontend/src/services/preferencesService.ts | 158 ++++++------------ 15 files changed, 136 insertions(+), 330 deletions(-) create mode 100644 frontend/src/constants/theme.ts create mode 100644 frontend/src/constants/toolPanel.ts delete mode 100644 frontend/src/hooks/tools/useJsonLocalStorageState.ts delete mode 100644 frontend/src/hooks/useToolPanelModePreference.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88c19649f..5db513d4a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,9 +40,9 @@ const LoadingFallback = () => ( export default function App() { return ( }> - - - + + + @@ -62,9 +62,9 @@ export default function App() { - - - + + + ); } diff --git a/frontend/src/components/shared/RainbowThemeProvider.tsx b/frontend/src/components/shared/RainbowThemeProvider.tsx index d8a7eb765..18efba256 100644 --- a/frontend/src/components/shared/RainbowThemeProvider.tsx +++ b/frontend/src/components/shared/RainbowThemeProvider.tsx @@ -6,9 +6,10 @@ import rainbowStyles from '../../styles/rainbow.module.css'; import { ToastProvider } from '../toast'; import ToastRenderer from '../toast/ToastRenderer'; import { ToastPortalBinder } from '../toast'; +import type { ThemeMode } from '../../constants/theme'; interface RainbowThemeContextType { - themeMode: 'light' | 'dark' | 'rainbow'; + themeMode: ThemeMode; isRainbowMode: boolean; isToggleDisabled: boolean; toggleTheme: () => void; diff --git a/frontend/src/components/shared/config/configSections/GeneralSection.tsx b/frontend/src/components/shared/config/configSections/GeneralSection.tsx index a79c15d72..937ff3d07 100644 --- a/frontend/src/components/shared/config/configSections/GeneralSection.tsx +++ b/frontend/src/components/shared/config/configSections/GeneralSection.tsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Paper, Stack, Switch, Text, Tooltip, NumberInput, SegmentedControl } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { usePreferences } from '../../../../contexts/PreferencesContext'; -import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; +import type { ToolPanelMode } from '../../../../constants/toolPanel'; const DEFAULT_AUTO_UNZIP_FILE_LIMIT = 4; diff --git a/frontend/src/components/tools/ToolPanel.tsx b/frontend/src/components/tools/ToolPanel.tsx index 88089df5d..099f83349 100644 --- a/frontend/src/components/tools/ToolPanel.tsx +++ b/frontend/src/components/tools/ToolPanel.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { usePreferences } from '../../contexts/PreferencesContext'; import ToolPicker from './ToolPicker'; import SearchResults from './SearchResults'; import ToolRenderer from './ToolRenderer'; @@ -14,7 +15,6 @@ import DoubleArrowIcon from '@mui/icons-material/DoubleArrow'; import { useTranslation } from 'react-i18next'; import FullscreenToolSurface from './FullscreenToolSurface'; import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry'; -import { useLocalStorageState } from '../../hooks/tools/useJsonLocalStorageState'; import { useRightRail } from '../../contexts/RightRailContext'; import { Tooltip } from '../shared/Tooltip'; import './ToolPanel.css'; @@ -45,6 +45,7 @@ export default function ToolPanel() { } = useToolWorkflow(); const { setAllRightRailButtonsDisabled } = useRightRail(); + const { preferences, updatePreference } = usePreferences(); const isFullscreenMode = toolPanelMode === 'fullscreen'; const toolPickerVisible = !readerMode; @@ -56,8 +57,6 @@ export default function ToolPanel() { setAllRightRailButtonsDisabled(fullscreenExpanded); }, [fullscreenExpanded, setAllRightRailButtonsDisabled]); - // Use custom hooks for state management - const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', false); const fullscreenGeometry = useToolPanelGeometry({ enabled: fullscreenExpanded, toolPanelRef, @@ -200,11 +199,11 @@ export default function ToolPanel() { toolRegistry={toolRegistry} filteredTools={filteredTools} selectedToolKey={selectedToolKey} - showDescriptions={showLegacyDescriptions} + showDescriptions={preferences.showLegacyToolDescriptions} matchedTextMap={matchedTextMap} onSearchChange={setSearchQuery} onSelect={(id: ToolId) => handleToolSelect(id)} - onToggleDescriptions={() => setShowLegacyDescriptions((prev) => !prev)} + onToggleDescriptions={() => updatePreference('showLegacyToolDescriptions', !preferences.showLegacyToolDescriptions)} onExitFullscreenMode={() => setToolPanelMode('sidebar')} toggleLabel={toggleLabel} geometry={fullscreenGeometry} diff --git a/frontend/src/components/tools/ToolPanelModePrompt.tsx b/frontend/src/components/tools/ToolPanelModePrompt.tsx index c6e9d3201..770cf2eee 100644 --- a/frontend/src/components/tools/ToolPanelModePrompt.tsx +++ b/frontend/src/components/tools/ToolPanelModePrompt.tsx @@ -2,17 +2,17 @@ import { useEffect, useState } from 'react'; import { Badge, Button, Card, Group, Modal, Stack, Text } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; +import { usePreferences } from '../../contexts/PreferencesContext'; import './ToolPanelModePrompt.css'; -import { useToolPanelModePreference } from '../../hooks/useToolPanelModePreference'; -import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; - -// type moved to hook +import type { ToolPanelMode } from '../../constants/toolPanel'; const ToolPanelModePrompt = () => { const { t } = useTranslation(); const { toolPanelMode, setToolPanelMode } = useToolWorkflow(); + const { preferences, updatePreference } = usePreferences(); const [opened, setOpened] = useState(false); - const { hydrated, shouldShowPrompt, markPromptSeen, setPreferredMode } = useToolPanelModePreference(); + + const shouldShowPrompt = !preferences.toolPanelModePromptSeen; useEffect(() => { if (shouldShowPrompt) { @@ -22,20 +22,16 @@ const ToolPanelModePrompt = () => { const handleSelect = (mode: ToolPanelMode) => { setToolPanelMode(mode); - setPreferredMode(mode); - markPromptSeen(); + updatePreference('defaultToolPanelMode', mode); + updatePreference('toolPanelModePromptSeen', true); setOpened(false); }; const handleDismiss = () => { - markPromptSeen(); + updatePreference('toolPanelModePromptSeen', true); setOpened(false); }; - if (!hydrated) { - return null; - } - return ( ( key: K, value: UserPreferences[K] - ) => Promise; - resetPreferences: () => Promise; - isLoading: boolean; + ) => void; + resetPreferences: () => void; } const PreferencesContext = createContext(undefined); export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { - const [preferences, setPreferences] = useState(DEFAULT_PREFERENCES); - const [isLoading, setIsLoading] = useState(true); - - useEffect(() => { - const loadPreferences = async () => { - try { - await preferencesService.initialize(); - const loadedPreferences = await preferencesService.getAllPreferences(); - setPreferences(loadedPreferences); - } catch (error) { - console.error('Failed to load preferences:', error); - // Keep default preferences on error - } finally { - setIsLoading(false); - } - }; - - loadPreferences(); - }, []); + const [preferences, setPreferences] = useState(() => { + // Load preferences synchronously on mount + return preferencesService.getAllPreferences(); + }); const updatePreference = useCallback( - async (key: K, value: UserPreferences[K]) => { - await preferencesService.setPreference(key, value); - setPreferences((prev) => ({ - ...prev, - [key]: value, - })); + (key: K, value: UserPreferences[K]) => { + preferencesService.setPreference(key, value); + setPreferences((prev) => ({ + ...prev, + [key]: value, + })); }, [] ); - const resetPreferences = useCallback(async () => { - await preferencesService.clearAllPreferences(); - setPreferences(DEFAULT_PREFERENCES); + const resetPreferences = useCallback(() => { + preferencesService.clearAllPreferences(); + setPreferences(preferencesService.getAllPreferences()); }, []); return ( @@ -56,7 +40,6 @@ export const PreferencesProvider: React.FC<{ children: React.ReactNode }> = ({ c preferences, updatePreference, resetPreferences, - isLoading, }} > {children} diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index d010dfdfd..378a7bdca 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -14,11 +14,10 @@ import { filterToolRegistryByQuery } from '../utils/toolSearch'; import { useToolHistory } from '../hooks/tools/useUserToolActivity'; import { ToolWorkflowState, - TOOL_PANEL_MODE_STORAGE_KEY, createInitialState, toolWorkflowReducer, - ToolPanelMode, } from './toolWorkflow/toolWorkflowState'; +import type { ToolPanelMode } from '../constants/toolPanel'; import { usePreferences } from './PreferencesContext'; // State interface @@ -74,7 +73,7 @@ interface ToolWorkflowProviderProps { export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const [state, dispatch] = useReducer(toolWorkflowReducer, undefined, createInitialState); - const { preferences } = usePreferences(); + const { preferences, updatePreference } = usePreferences(); // Store reset functions for tools const [toolResetFunctions, setToolResetFunctions] = React.useState void>>({}); @@ -118,7 +117,8 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { const setToolPanelMode = useCallback((mode: ToolPanelMode) => { dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: mode }); - }, []); + updatePreference('defaultToolPanelMode', mode); + }, [updatePreference]); const setPreviewFile = useCallback((file: File | null) => { @@ -136,27 +136,15 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { dispatch({ type: 'SET_SEARCH_QUERY', payload: query }); }, []); - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, state.toolPanelMode); - }, [state.toolPanelMode]); - // Keep tool panel mode in sync with user preference. This ensures the // Config setting (Default tool picker mode) immediately affects the app // and persists across reloads. useEffect(() => { - if (!preferences) return; const preferredMode = preferences.defaultToolPanelMode; - if (preferredMode && preferredMode !== state.toolPanelMode) { + if (preferredMode !== state.toolPanelMode) { dispatch({ type: 'SET_TOOL_PANEL_MODE', payload: preferredMode }); - if (typeof window !== 'undefined') { - window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, preferredMode); - } } - }, [preferences.defaultToolPanelMode]); + }, [preferences.defaultToolPanelMode, state.toolPanelMode]); // Tool reset methods const registerToolReset = useCallback((toolId: string, resetFunction: () => void) => { diff --git a/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts b/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts index dbf96c082..0322e9094 100644 --- a/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts +++ b/frontend/src/contexts/toolWorkflow/toolWorkflowState.ts @@ -1,7 +1,5 @@ import { PageEditorFunctions } from '../../types/pageEditor'; - -// State & Modes -export type ToolPanelMode = 'sidebar' | 'fullscreen'; +import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '../../constants/toolPanel'; export interface ToolWorkflowState { // UI State @@ -28,22 +26,6 @@ export type ToolWorkflowAction = | { type: 'SET_SEARCH_QUERY'; payload: string } | { type: 'RESET_UI_STATE' }; -// Storage keys -export const TOOL_PANEL_MODE_STORAGE_KEY = 'toolPanelModePreference'; - -export const getStoredToolPanelMode = (): ToolPanelMode => { - if (typeof window === 'undefined') { - return 'sidebar'; - } - - const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY); - if (stored === 'fullscreen') { - return 'fullscreen'; - } - - return 'sidebar'; -}; - export const baseState: Omit = { sidebarsVisible: true, leftPanelView: 'toolPicker', @@ -55,7 +37,7 @@ export const baseState: Omit = { export const createInitialState = (): ToolWorkflowState => ({ ...baseState, - toolPanelMode: getStoredToolPanelMode(), + toolPanelMode: DEFAULT_TOOL_PANEL_MODE, }); export function toolWorkflowReducer(state: ToolWorkflowState, action: ToolWorkflowAction): ToolWorkflowState { diff --git a/frontend/src/hooks/tools/useJsonLocalStorageState.ts b/frontend/src/hooks/tools/useJsonLocalStorageState.ts deleted file mode 100644 index 739251f4e..000000000 --- a/frontend/src/hooks/tools/useJsonLocalStorageState.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useState, useEffect, Dispatch, SetStateAction } from 'react'; - -export function useLocalStorageState(key: string, defaultValue: T): [T, Dispatch>] { - const [state, setState] = useState(() => { - if (typeof window === 'undefined') { - return defaultValue; - } - - const stored = window.localStorage.getItem(key); - if (stored === null) { - return defaultValue; - } - - try { - return JSON.parse(stored) as T; - } catch { - return defaultValue; - } - }); - - useEffect(() => { - if (typeof window === 'undefined') { - return; - } - - window.localStorage.setItem(key, JSON.stringify(state)); - }, [key, state]); - - return [state, setState]; -} diff --git a/frontend/src/hooks/useRainbowTheme.ts b/frontend/src/hooks/useRainbowTheme.ts index 8b272b883..5fc049007 100644 --- a/frontend/src/hooks/useRainbowTheme.ts +++ b/frontend/src/hooks/useRainbowTheme.ts @@ -1,6 +1,6 @@ -import { useState, useCallback, useRef, useEffect } from 'react'; - -type ThemeMode = 'light' | 'dark' | 'rainbow'; +import { useCallback, useRef, useEffect } from 'react'; +import { usePreferences } from '../contexts/PreferencesContext'; +import type { ThemeMode } from '../constants/theme'; interface RainbowThemeHook { themeMode: ThemeMode; @@ -13,36 +13,19 @@ interface RainbowThemeHook { const allowRainbowMode = false; // Override to allow/disallow fun -export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): RainbowThemeHook { - // Get theme from localStorage or use initial - const [themeMode, setThemeMode] = useState(() => { - const stored = localStorage.getItem('stirling-theme'); - if (stored && ['light', 'dark', 'rainbow'].includes(stored)) { - return stored as ThemeMode; - } - try { - // Fallback to OS preference if available - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : initialTheme; - } catch { - return initialTheme; - } - }); +export function useRainbowTheme(): RainbowThemeHook { + const { preferences, updatePreference } = usePreferences(); + const themeMode = preferences.theme; // Track rapid toggles for easter egg const toggleCount = useRef(0); const lastToggleTime = useRef(Date.now()); - const [isToggleDisabled, setIsToggleDisabled] = useState(false); + const isToggleDisabled = useRef(false); - // Save theme to localStorage whenever it changes + // Apply rainbow class to body whenever theme changes useEffect(() => { - localStorage.setItem('stirling-theme', themeMode); - - // Apply rainbow class to body if in rainbow mode if (themeMode === 'rainbow') { document.body.classList.add('rainbow-mode-active'); - - // Show easter egg notification showRainbowNotification(); } else { document.body.classList.remove('rainbow-mode-active'); @@ -141,7 +124,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb const toggleTheme = useCallback(() => { // Don't allow toggle if disabled - if (isToggleDisabled) { + if (isToggleDisabled.current) { return; } @@ -149,7 +132,7 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb // Simple exit from rainbow mode with single click (after cooldown period) if (themeMode === 'rainbow') { - setThemeMode('light'); + updatePreference('theme', 'light'); console.log('🌈 Rainbow mode deactivated. Thanks for trying it!'); showExitNotification(); return; @@ -165,14 +148,14 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb // Easter egg: Activate rainbow mode after 10 rapid toggles if (allowRainbowMode && toggleCount.current >= 10) { - setThemeMode('rainbow'); + updatePreference('theme', 'rainbow'); console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!'); console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!'); // Disable toggle for 3 seconds - setIsToggleDisabled(true); + isToggleDisabled.current = true; setTimeout(() => { - setIsToggleDisabled(false); + isToggleDisabled.current = false; console.log('🌈 Theme toggle re-enabled! Click once to exit rainbow mode.'); }, 3000); @@ -182,25 +165,26 @@ export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): Rainb } // Normal theme switching - setThemeMode(prevMode => prevMode === 'light' ? 'dark' : 'light'); - }, [themeMode, isToggleDisabled]); + const nextTheme = themeMode === 'light' ? 'dark' : 'light'; + updatePreference('theme', nextTheme); + }, [themeMode, updatePreference]); const activateRainbow = useCallback(() => { - setThemeMode('rainbow'); + updatePreference('theme', 'rainbow'); console.log('🌈 Rainbow mode manually activated!'); - }, []); + }, [updatePreference]); const deactivateRainbow = useCallback(() => { if (themeMode === 'rainbow') { - setThemeMode('light'); + updatePreference('theme', 'light'); console.log('🌈 Rainbow mode manually deactivated.'); } - }, [themeMode]); + }, [themeMode, updatePreference]); return { themeMode, isRainbowMode: themeMode === 'rainbow', - isToggleDisabled, + isToggleDisabled: isToggleDisabled.current, toggleTheme, activateRainbow, deactivateRainbow, diff --git a/frontend/src/hooks/useToolPanelModePreference.ts b/frontend/src/hooks/useToolPanelModePreference.ts deleted file mode 100644 index b8656eeae..000000000 --- a/frontend/src/hooks/useToolPanelModePreference.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { TOOL_PANEL_MODE_STORAGE_KEY, ToolPanelMode } from '../contexts/toolWorkflow/toolWorkflowState'; - -const PROMPT_SEEN_KEY = 'toolPanelModePromptSeen'; - -export function useToolPanelModePreference() { - const [hydrated, setHydrated] = useState(false); - - const getPreferredMode = useCallback((): ToolPanelMode | null => { - if (typeof window === 'undefined') return null; - const stored = window.localStorage.getItem(TOOL_PANEL_MODE_STORAGE_KEY); - return stored === 'sidebar' || stored === 'fullscreen' ? stored : null; - }, []); - - const setPreferredMode = useCallback((mode: ToolPanelMode) => { - if (typeof window === 'undefined') return; - window.localStorage.setItem(TOOL_PANEL_MODE_STORAGE_KEY, mode); - }, []); - - const hasSeenPrompt = useCallback((): boolean => { - if (typeof window === 'undefined') return true; - return window.localStorage.getItem(PROMPT_SEEN_KEY) === 'true'; - }, []); - - const markPromptSeen = useCallback(() => { - if (typeof window === 'undefined') return; - window.localStorage.setItem(PROMPT_SEEN_KEY, 'true'); - }, []); - - const shouldShowPrompt = useMemo(() => { - const seen = hasSeenPrompt(); - const pref = getPreferredMode(); - return !seen && !pref; - }, [getPreferredMode, hasSeenPrompt]); - - useEffect(() => { - setHydrated(true); - }, []); - - return { - hydrated, - getPreferredMode, - setPreferredMode, - hasSeenPrompt, - markPromptSeen, - shouldShowPrompt, - } as const; -} - - diff --git a/frontend/src/index.tsx b/frontend/src/index.tsx index 431aa7bf3..083d779a5 100644 --- a/frontend/src/index.tsx +++ b/frontend/src/index.tsx @@ -12,18 +12,6 @@ import posthog from 'posthog-js'; import { PostHogProvider } from 'posthog-js/react'; import { BASE_PATH } from './constants/app'; -// Compute initial color scheme -function getInitialScheme(): 'light' | 'dark' { - const stored = localStorage.getItem('stirling-theme'); - if (stored === 'light' || stored === 'dark') return stored; - try { - const prefersDark = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches; - return prefersDark ? 'dark' : 'light'; - } catch { - return 'light'; - } -} - posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, { api_host: import.meta.env.VITE_PUBLIC_POSTHOG_HOST, defaults: '2025-05-24', @@ -57,7 +45,7 @@ if (!container) { const root = ReactDOM.createRoot(container); // Finds the root DOM element root.render( - + diff --git a/frontend/src/services/preferencesService.ts b/frontend/src/services/preferencesService.ts index 2f126b297..5a8ff6286 100644 --- a/frontend/src/services/preferencesService.ts +++ b/frontend/src/services/preferencesService.ts @@ -1,131 +1,83 @@ -import { ToolPanelMode } from 'src/contexts/toolWorkflow/toolWorkflowState'; -import { indexedDBManager, DATABASE_CONFIGS } from './indexedDBManager'; +import { type ToolPanelMode, DEFAULT_TOOL_PANEL_MODE } from '../constants/toolPanel'; +import { type ThemeMode, getSystemTheme } from '../constants/theme'; export interface UserPreferences { autoUnzip: boolean; autoUnzipFileLimit: number; defaultToolPanelMode: ToolPanelMode; + theme: ThemeMode; + toolPanelModePromptSeen: boolean; + showLegacyToolDescriptions: boolean; } export const DEFAULT_PREFERENCES: UserPreferences = { autoUnzip: true, autoUnzipFileLimit: 4, - defaultToolPanelMode: 'sidebar', + defaultToolPanelMode: DEFAULT_TOOL_PANEL_MODE, + theme: getSystemTheme(), + toolPanelModePromptSeen: false, + showLegacyToolDescriptions: false, }; +const STORAGE_KEY = 'stirlingpdf_preferences'; + class PreferencesService { - private db: IDBDatabase | null = null; - - async initialize(): Promise { - this.db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.PREFERENCES); - } - - private ensureDatabase(): IDBDatabase { - if (!this.db) { - throw new Error('PreferencesService not initialized. Call initialize() first.'); - } - return this.db; - } - - async getPreference( + getPreference( key: K - ): Promise { - const db = this.ensureDatabase(); - - return new Promise((resolve) => { - const transaction = db.transaction(['preferences'], 'readonly'); - const store = transaction.objectStore('preferences'); - const request = store.get(key); - - request.onsuccess = () => { - const result = request.result; - if (result && result.value !== undefined) { - resolve(result.value); - } else { - // Return default value if preference not found - resolve(DEFAULT_PREFERENCES[key]); + ): UserPreferences[K] { + // Explicitly re-read every time in case preferences have changed in another tab etc. + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const preferences = JSON.parse(stored) as Partial; + if (key in preferences && preferences[key] !== undefined) { + return preferences[key]!; } - }; - - request.onerror = () => { - console.error('Error reading preference:', key, request.error); - // Return default value on error - resolve(DEFAULT_PREFERENCES[key]); - }; - }); + } + } catch (error) { + console.error('Error reading preference:', key, error); + } + return DEFAULT_PREFERENCES[key]; } - async setPreference( + setPreference( key: K, value: UserPreferences[K] - ): Promise { - const db = this.ensureDatabase(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction(['preferences'], 'readwrite'); - const store = transaction.objectStore('preferences'); - const request = store.put({ key, value }); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - console.error('Error writing preference:', key, request.error); - reject(request.error); - }; - }); + ): void { + try { + const stored = localStorage.getItem(STORAGE_KEY); + const preferences = stored ? JSON.parse(stored) : {}; + preferences[key] = value; + localStorage.setItem(STORAGE_KEY, JSON.stringify(preferences)); + } catch (error) { + console.error('Error writing preference:', key, error); + } } - async getAllPreferences(): Promise { - const db = this.ensureDatabase(); - - return new Promise((resolve) => { - const transaction = db.transaction(['preferences'], 'readonly'); - const store = transaction.objectStore('preferences'); - const request = store.getAll(); - - request.onsuccess = () => { - const storedPrefs: Partial = {}; - const results = request.result; - - for (const item of results) { - if (item.key && item.value !== undefined) { - storedPrefs[item.key as keyof UserPreferences] = item.value; - } - } - + getAllPreferences(): UserPreferences { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) { + const preferences = JSON.parse(stored) as Partial; // Merge with defaults to ensure all preferences exist - resolve({ + return { ...DEFAULT_PREFERENCES, - ...storedPrefs, - }); - }; - - request.onerror = () => { - console.error('Error reading all preferences:', request.error); - // Return defaults on error - resolve({ ...DEFAULT_PREFERENCES }); - }; - }); + ...preferences, + }; + } + } catch (error) { + console.error('Error reading preferences', error); + } + return { ...DEFAULT_PREFERENCES }; } - async clearAllPreferences(): Promise { - const db = this.ensureDatabase(); - - return new Promise((resolve, reject) => { - const transaction = db.transaction(['preferences'], 'readwrite'); - const store = transaction.objectStore('preferences'); - const request = store.clear(); - - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(request.error); - }; - }); + clearAllPreferences(): void { + try { + localStorage.removeItem(STORAGE_KEY); + } catch (error) { + console.error('Error clearing preferences:', error); + throw error; + } } } From bcd776259460d4bac52208beca84201fcbcb3e4a Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 15 Oct 2025 14:05:32 +0100 Subject: [PATCH 2/2] Replace kebab menu in file editor with on hover menu (#4624) Replace kebab menu in file editor with on hover menu by refactoring page editor's menu into a new component. In mobile sizes, the hover menus are always visible. --- .../public/locales/en-GB/translation.json | 5 + .../fileEditor/FileEditor.module.css | 2 +- .../fileEditor/FileEditorThumbnail.tsx | 227 ++++++++---------- .../components/pageEditor/PageThumbnail.tsx | 202 ++++++---------- .../shared/HoverActionMenu.module.css | 28 +++ .../src/components/shared/HoverActionMenu.tsx | 60 +++++ 6 files changed, 270 insertions(+), 254 deletions(-) create mode 100644 frontend/src/components/shared/HoverActionMenu.module.css create mode 100644 frontend/src/components/shared/HoverActionMenu.tsx diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 8294d34f5..f6f8d828c 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -90,6 +90,11 @@ "moreOptions": "More Options", "editYourNewFiles": "Edit your new file(s)", "close": "Close", + "openInViewer": "Open in Viewer", + "confirmClose": "Confirm Close", + "confirmCloseMessage": "Are you sure you want to close this file?", + "confirmCloseCancel": "Cancel", + "confirmCloseConfirm": "Close File", "fileSelected": "Selected: {{filename}}", "chooseFile": "Choose File", "filesSelected": "{{count}} files selected", diff --git a/frontend/src/components/fileEditor/FileEditor.module.css b/frontend/src/components/fileEditor/FileEditor.module.css index 17184bbf4..4f26c8bce 100644 --- a/frontend/src/components/fileEditor/FileEditor.module.css +++ b/frontend/src/components/fileEditor/FileEditor.module.css @@ -9,7 +9,7 @@ transition: box-shadow 0.18s ease, outline-color 0.18s ease, transform 0.18s ease; max-width: 100%; max-height: 100%; - overflow: hidden; + overflow: visible; margin-left: 0.5rem; margin-right: 0.5rem; } diff --git a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx index bf3ddd885..5f14779a6 100644 --- a/frontend/src/components/fileEditor/FileEditorThumbnail.tsx +++ b/frontend/src/components/fileEditor/FileEditorThumbnail.tsx @@ -1,10 +1,11 @@ -import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { Text, ActionIcon, CheckboxIndicator, Tooltip } from '@mantine/core'; +import React, { useState, useCallback, useRef, useMemo } from 'react'; +import { Text, ActionIcon, CheckboxIndicator, Tooltip, Modal, Button, Group, Stack } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import { alert } from '../toast'; import { useTranslation } from 'react-i18next'; -import MoreVertIcon from '@mui/icons-material/MoreVert'; import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined'; import CloseIcon from '@mui/icons-material/Close'; +import VisibilityIcon from '@mui/icons-material/Visibility'; import UnarchiveIcon from '@mui/icons-material/Unarchive'; import PushPinIcon from '@mui/icons-material/PushPin'; import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined'; @@ -19,6 +20,7 @@ import { useFileState } from '../../contexts/file/fileHooks'; import { FileId } from '../../types/file'; import { formatFileSize } from '../../utils/fileUtils'; import ToolChain from '../shared/ToolChain'; +import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu'; @@ -60,8 +62,9 @@ const FileEditorThumbnail = ({ // ---- Drag state ---- const [isDragging, setIsDragging] = useState(false); const dragElementRef = useRef(null); - const [actionsWidth, setActionsWidth] = useState(undefined); - const [showActions, setShowActions] = useState(false); + const [showHoverMenu, setShowHoverMenu] = useState(false); + const isMobile = useMediaQuery('(max-width: 1024px)'); + const [showCloseModal, setShowCloseModal] = useState(false); // Resolve the actual File object for pin/unpin operations const actualFile = useMemo(() => { @@ -155,46 +158,66 @@ const FileEditorThumbnail = ({ }; }, [file.id, file.name, selectedFiles, onReorderFiles]); - // Update dropdown width on resize - useEffect(() => { - const update = () => { - if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth); - }; - update(); - window.addEventListener('resize', update); - return () => window.removeEventListener('resize', update); + // Handle close with confirmation + const handleCloseWithConfirmation = useCallback(() => { + setShowCloseModal(true); }, []); - // Close the actions dropdown when hovering outside this file card (and its dropdown) - useEffect(() => { - if (!showActions) return; + const handleConfirmClose = useCallback(() => { + onCloseFile(file.id); + alert({ alertType: 'neutral', title: `Closed ${file.name}`, expandable: false, durationMs: 3500 }); + setShowCloseModal(false); + }, [file.id, file.name, onCloseFile]); - const isInsideCard = (target: EventTarget | null) => { - const container = dragElementRef.current; - if (!container) return false; - return target instanceof Node && container.contains(target); - }; + const handleCancelClose = useCallback(() => { + setShowCloseModal(false); + }, []); - const handleMouseMove = (e: MouseEvent) => { - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - const handleTouchStart = (e: TouchEvent) => { - // On touch devices, close if the touch target is outside the card - if (!isInsideCard(e.target)) { - setShowActions(false); - } - }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('touchstart', handleTouchStart, { passive: true }); - return () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('touchstart', handleTouchStart); - }; - }, [showActions]); + // Build hover menu actions + const hoverActions = useMemo(() => [ + { + id: 'view', + icon: , + label: t('openInViewer', 'Open in Viewer'), + onClick: (e) => { + e.stopPropagation(); + onViewFile(file.id); + }, + }, + { + id: 'download', + icon: , + label: t('download', 'Download'), + onClick: (e) => { + e.stopPropagation(); + onDownloadFile(file.id); + alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); + }, + }, + { + id: 'unzip', + icon: , + label: t('fileManager.unzip', 'Unzip'), + onClick: (e) => { + e.stopPropagation(); + if (onUnzipFile) { + onUnzipFile(file.id); + alert({ alertType: 'success', title: `Unzipping ${file.name}`, expandable: false, durationMs: 2500 }); + } + }, + hidden: !isZipFile || !onUnzipFile, + }, + { + id: 'close', + icon: , + label: t('close', 'Close'), + onClick: (e) => { + e.stopPropagation(); + handleCloseWithConfirmation(); + }, + color: 'red', + } + ], [t, file.id, file.name, isZipFile, onViewFile, onDownloadFile, onUnzipFile, handleCloseWithConfirmation]); // ---- Card interactions ---- const handleCardClick = () => { @@ -232,6 +255,8 @@ const FileEditorThumbnail = ({ role="listitem" aria-selected={isSelected} onClick={handleCardClick} + onMouseEnter={() => setShowHoverMenu(true)} + onMouseLeave={() => setShowHoverMenu(false)} onDoubleClick={handleCardDoubleClick} > {/* Header bar */} @@ -289,98 +314,9 @@ const FileEditorThumbnail = ({ {isPinned ? : } - - {/* Download icon */} - - { - e.stopPropagation(); - onDownloadFile(file.id); - alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 }); - }} - > - - - - - {/* Kebab menu */} - { - e.stopPropagation(); - setShowActions((v) => !v); - }} - > - - - {/* Actions overlay */} - {showActions && ( -
e.stopPropagation()} - > - - - - - {isZipFile && onUnzipFile && ( - - )} - -
- - -
- )} - {/* Title + meta line */}
)}
+ + {/* Hover Menu */} + + + {/* Close Confirmation Modal */} + + + {t('confirmCloseMessage', 'Are you sure you want to close this file?')} + + {file.name} + + + + + + +
); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 59e5819d9..f6d77a3e4 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -1,5 +1,6 @@ -import React, { useCallback, useState, useEffect, useRef } from 'react'; -import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core'; +import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react'; +import { Text, Checkbox } from '@mantine/core'; +import { useMediaQuery } from '@mantine/hooks'; import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import RotateLeftIcon from '@mui/icons-material/RotateLeft'; @@ -12,6 +13,7 @@ import { PDFPage, PDFDocument } from '../../types/pageEditor'; import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration'; import { useFilesModalContext } from '../../contexts/FilesModalContext'; import styles from './PageEditor.module.css'; +import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu'; interface PageThumbnailProps { @@ -65,6 +67,8 @@ const PageThumbnail: React.FC = ({ const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); + const [isHovered, setIsHovered] = useState(false); + const isMobile = useMediaQuery('(max-width: 1024px)'); const dragElementRef = useRef(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); @@ -270,8 +274,75 @@ const PageThumbnail: React.FC = ({ const handleMouseLeave = useCallback(() => { setIsMouseDown(false); setMouseStartPos(null); + setIsHovered(false); }, []); + // Build hover menu actions + const hoverActions = useMemo(() => [ + { + id: 'move-left', + icon: , + label: 'Move Left', + onClick: (e) => { + e.stopPropagation(); + if (index > 0 && !movingPage && !isAnimating) { + onSetMovingPage(page.pageNumber); + onReorderPages(page.pageNumber, index - 1); + setTimeout(() => onSetMovingPage(null), 650); + onSetStatus(`Moved page ${page.pageNumber} left`); + } + }, + disabled: index === 0 + }, + { + id: 'move-right', + icon: , + label: 'Move Right', + onClick: (e) => { + e.stopPropagation(); + if (index < totalPages - 1 && !movingPage && !isAnimating) { + onSetMovingPage(page.pageNumber); + onReorderPages(page.pageNumber, index + 1); + setTimeout(() => onSetMovingPage(null), 650); + onSetStatus(`Moved page ${page.pageNumber} right`); + } + }, + disabled: index === totalPages - 1 + }, + { + id: 'rotate-left', + icon: , + label: 'Rotate Left', + onClick: handleRotateLeft, + }, + { + id: 'rotate-right', + icon: , + label: 'Rotate Right', + onClick: handleRotateRight, + }, + { + id: 'delete', + icon: , + label: 'Delete Page', + onClick: handleDelete, + color: 'red', + }, + { + id: 'split', + icon: , + label: 'Split After', + onClick: handleSplit, + hidden: index >= totalPages - 1, + }, + { + id: 'insert', + icon: , + label: 'Insert File After', + onClick: handleInsertFileAfter, + } + ], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]); + return (
= ({ draggable={false} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} + onMouseEnter={() => setIsHovered(true)} onMouseLeave={handleMouseLeave} > { @@ -413,128 +485,12 @@ const PageThumbnail: React.FC = ({ {page.pageNumber} -
e.stopPropagation()} - onMouseUp={(e) => e.stopPropagation()} - onClick={(e) => e.stopPropagation()} - > - - { - e.stopPropagation(); - if (index > 0 && !movingPage && !isAnimating) { - onSetMovingPage(page.pageNumber); - // Actually move the page left (swap with previous page) - onReorderPages(page.pageNumber, index - 1); - setTimeout(() => onSetMovingPage(null), 650); - onSetStatus(`Moved page ${page.pageNumber} left`); - } - }} - > - - - - - - { - e.stopPropagation(); - if (index < totalPages - 1 && !movingPage && !isAnimating) { - onSetMovingPage(page.pageNumber); - // Actually move the page right (swap with next page) - onReorderPages(page.pageNumber, index + 1); - setTimeout(() => onSetMovingPage(null), 650); - onSetStatus(`Moved page ${page.pageNumber} right`); - } - }} - > - - - - - - - - - - - - - - - - - - - - - - - {index < totalPages - 1 && ( - - - - - - )} - - - - - - -
+ />
diff --git a/frontend/src/components/shared/HoverActionMenu.module.css b/frontend/src/components/shared/HoverActionMenu.module.css new file mode 100644 index 000000000..27974f152 --- /dev/null +++ b/frontend/src/components/shared/HoverActionMenu.module.css @@ -0,0 +1,28 @@ +/* Base Hover Menu */ +.hoverMenu { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 8px; + align-items: center; + background: var(--bg-toolbar); + border: 1px solid var(--border-default); + padding: 6px 12px; + border-radius: 20px; + box-shadow: var(--shadow-md); + z-index: 30; + white-space: nowrap; + pointer-events: auto; + transition: opacity 0.2s ease-in-out; +} + +/* Inside positioning (Page Editor style) - within container */ +.inside { + bottom: 8px; +} + +/* Outside positioning (File Editor style) - below container */ +.outside { + bottom: -8px; +} diff --git a/frontend/src/components/shared/HoverActionMenu.tsx b/frontend/src/components/shared/HoverActionMenu.tsx new file mode 100644 index 000000000..1c55f5f0a --- /dev/null +++ b/frontend/src/components/shared/HoverActionMenu.tsx @@ -0,0 +1,60 @@ +import React from 'react'; +import { ActionIcon, Tooltip } from '@mantine/core'; +import styles from './HoverActionMenu.module.css'; + +export interface HoverAction { + id: string; + icon: React.ReactNode; + label: string; + onClick: (e: React.MouseEvent) => void; + disabled?: boolean; + color?: string; + hidden?: boolean; +} + +interface HoverActionMenuProps { + show: boolean; + actions: HoverAction[]; + position?: 'inside' | 'outside'; + className?: string; +} + +const HoverActionMenu: React.FC = ({ + show, + actions, + position = 'inside', + className = '' +}) => { + const visibleActions = actions.filter(action => !action.hidden); + + if (visibleActions.length === 0) { + return null; + } + + return ( +
e.stopPropagation()} + onMouseUp={(e) => e.stopPropagation()} + onClick={(e) => e.stopPropagation()} + > + {visibleActions.map((action) => ( + + + {action.icon} + + + ))} +
+ ); +}; + +export default HoverActionMenu;