From 28e45917a256b81ed5f44711088d30eaee0aa52d Mon Sep 17 00:00:00 2001 From: James Brunton Date: Wed, 15 Oct 2025 11:53:00 +0100 Subject: [PATCH 1/5] 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/5] 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; From 43887c817966a1f2450ec705d485d48dea2d4903 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Wed, 15 Oct 2025 15:17:44 +0100 Subject: [PATCH 3/5] Fix/V2/unzip_images (#4647) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Method Usage by Context | Context | Method Used | Respects Preferences | HTML Detection | |------------------------------|-------------------------------------------------------|------------------------|----------------| | Tools (via useToolResources) | extractZipFiles() → extractWithPreferences() | ✅ Yes | ✅ Yes | | Automation | extractAutomationZipFiles() → extractAllFiles() | ❌ No (always extracts) | ✅ Yes | | Manual Unzip | extractAndStoreFilesWithHistory() → extractAllFiles() | ❌ No (always extracts) | ✅ Yes | | Auto-Upload | extractAllFiles() directly | ❌ No (always extracts) | ✅ Yes | Detailed Behavior Matrix | Context | HTML Files | Auto-Unzip OFF | Within Limit | Exceeds Limit | Notes | |--------------------------|-------------|----------------|--------------|---------------|----------------------------------------| | Tools (useToolResources) | Keep zipped | Keep zipped | Extract all | Keep zipped | Respects user preferences | | Automation | Keep zipped | Extract all | Extract all | Extract all | Ignores preferences (automation needs) | | Manual Unzip | Keep zipped | Extract all | Extract all | Extract all | User explicitly unzipping | | Auto-Upload | Keep zipped | Extract all | Extract all | Extract all | User dropped files | Simplified Decision Flow ZIP File Received │ ├─ Contains HTML? → Keep as ZIP (all contexts) │ └─ No HTML │ ├─ Tools Context │ ├─ Auto-unzip OFF? → Keep as ZIP │ └─ Auto-unzip ON │ ├─ File count ≤ limit? → Extract all │ └─ File count > limit? → Keep as ZIP │ └─ Automation/Manual/Auto-Upload └─ Extract all (ignore preferences) Key Changes from Previous Version | Entry Point | Code Path | skipAutoUnzip | Respects Preferences? | HTML Detection? | Extraction Behavior | |-----------------------------------------------|----------------------------------------------------------------------------------------|---------------|-----------------------|---------------------------|-------------------------------------------------------------------------| | Direct File Upload (FileEditor, LandingPage) | FileContext.addRawFiles() → fileActions.addFiles() | True | ❌ No | ✅ Yes | Always extract (except HTML ZIPs) | | Tool Outputs (Split, Merge, etc.) | useToolResources.extractZipFiles() → zipFileService.extractWithPreferences() | false | ✅ Yes | ✅ Yes | Conditional: Only if autoUnzip=true AND file count ≤ autoUnzipFileLimit | | Load from Storage (FileManager) | fileActions.addStirlingFileStubs() | N/A | N/A | N/A | No extraction - files already processed | | Automation Outputs | AutomationFileProcessor.extractAutomationZipFiles() → zipFileService.extractAllFiles() | N/A | ❌ No | ✅ Yes | Always extract (except HTML ZIPs) | | Manual Unzip Action (FileEditor context menu) | zipFileService.extractAndStoreFilesWithHistory() → extractAllFiles() | N/A | ❌ No | ✅ Yes (blocks extraction) | Always extract (except HTML ZIPs) - explicit user action | --------- Co-authored-by: Connor Yoh --- .../src/components/fileEditor/FileEditor.tsx | 155 ++---------------- frontend/src/contexts/FileContext.tsx | 17 +- frontend/src/contexts/file/fileActions.ts | 57 +++++++ .../useExtractImagesOperation.ts | 32 ++-- .../useScannerImageSplitOperation.ts | 6 +- .../hooks/tools/shared/useToolOperation.ts | 9 +- .../hooks/tools/shared/useToolResources.ts | 46 +----- frontend/src/services/zipFileService.ts | 116 ++++++++++++- frontend/src/utils/automationFileProcessor.ts | 42 +++-- 9 files changed, 239 insertions(+), 241 deletions(-) diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 5c76d2248..856c55b83 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,6 +1,6 @@ import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { - Text, Center, Box, LoadingOverlay, Stack, Group + Text, Center, Box, LoadingOverlay, Stack } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; @@ -10,7 +10,6 @@ import { detectFileExtension } from '../../utils/fileUtils'; import FileEditorThumbnail from './FileEditorThumbnail'; import AddFileCard from './AddFileCard'; import FilePickerModal from '../shared/FilePickerModal'; -import SkeletonLoader from '../shared/SkeletonLoader'; import { FileId, StirlingFile } from '../../types/fileContext'; import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; @@ -68,19 +67,6 @@ const FileEditor = ({ } }, [toolMode]); const [showFilePickerModal, setShowFilePickerModal] = useState(false); - const [zipExtractionProgress, setZipExtractionProgress] = useState<{ - isExtracting: boolean; - currentFile: string; - progress: number; - extractedCount: number; - totalFiles: number; - }>({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); // Get selected file IDs from context (defensive programming) const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : []; @@ -92,106 +78,26 @@ const FileEditor = ({ const localSelectedIds = contextSelectedIds; // Process uploaded files using context + // ZIP extraction is now handled automatically in FileContext based on user preferences const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { _setError(null); try { - const allExtractedFiles: File[] = []; - const errors: string[] = []; - - for (const file of uploadedFiles) { - if (file.type === 'application/pdf') { - // Handle PDF files normally - allExtractedFiles.push(file); - } else if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) { - // Handle ZIP files - only expand if they contain PDFs - try { - // Validate ZIP file first - const validation = await zipFileService.validateZipFile(file); - - if (validation.isValid && validation.containsPDFs) { - // ZIP contains PDFs - extract them - setZipExtractionProgress({ - isExtracting: true, - currentFile: file.name, - progress: 0, - extractedCount: 0, - totalFiles: validation.fileCount - }); - - const extractionResult = await zipFileService.extractPdfFiles(file, (progress) => { - setZipExtractionProgress({ - isExtracting: true, - currentFile: progress.currentFile, - progress: progress.progress, - extractedCount: progress.extractedCount, - totalFiles: progress.totalFiles - }); - }); - - // Reset extraction progress - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); - - if (extractionResult.success) { - allExtractedFiles.push(...extractionResult.extractedFiles); - - if (extractionResult.errors.length > 0) { - errors.push(...extractionResult.errors); - } - } else { - errors.push(`Failed to extract ZIP file "${file.name}": ${extractionResult.errors.join(', ')}`); - } - } else { - // ZIP doesn't contain PDFs or is invalid - treat as regular file - allExtractedFiles.push(file); - } - } catch (zipError) { - errors.push(`Failed to process ZIP file "${file.name}": ${zipError instanceof Error ? zipError.message : 'Unknown error'}`); - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); - } - } else { - allExtractedFiles.push(file); - } - } - - // Show any errors - if (errors.length > 0) { - showError(errors.join('\n')); - } - - // Process all extracted files - if (allExtractedFiles.length > 0) { - // Add files to context and select them automatically - await addFiles(allExtractedFiles, { selectFiles: true }); - showStatus(`Added ${allExtractedFiles.length} files`, 'success'); + if (uploadedFiles.length > 0) { + // FileContext will automatically handle ZIP extraction based on user preferences + // - Respects autoUnzip setting + // - Respects autoUnzipFileLimit + // - HTML ZIPs stay intact + // - Non-ZIP files pass through unchanged + await addFiles(uploadedFiles, { selectFiles: true }); + showStatus(`Added ${uploadedFiles.length} file(s)`, 'success'); } } catch (err) { const errorMessage = err instanceof Error ? err.message : 'Failed to process files'; showError(errorMessage); console.error('File processing error:', err); - - // Reset extraction progress on error - setZipExtractionProgress({ - isExtracting: false, - currentFile: '', - progress: 0, - extractedCount: 0, - totalFiles: 0 - }); } - }, [addFiles]); + }, [addFiles, showStatus, showError]); const toggleFile = useCallback((fileId: FileId) => { const currentSelectedIds = contextSelectedIdsRef.current; @@ -394,7 +300,7 @@ const FileEditor = ({ - {activeStirlingFileStubs.length === 0 && !zipExtractionProgress.isExtracting ? ( + {activeStirlingFileStubs.length === 0 ? (
📁 @@ -402,43 +308,6 @@ const FileEditor = ({ Upload PDF files, ZIP archives, or load from storage to get started
- ) : activeStirlingFileStubs.length === 0 && zipExtractionProgress.isExtracting ? ( - - - - {/* ZIP Extraction Progress */} - {zipExtractionProgress.isExtracting && ( - - - Extracting ZIP archive... - {Math.round(zipExtractionProgress.progress)}% - - - {zipExtractionProgress.currentFile || 'Processing files...'} - - - {zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted - -
-
-
- - )} - - - - ) : (
=> { - const stirlingFiles = await addFiles({ files, ...options }, stateRef, filesRef, dispatch, lifecycleManager, enablePersistence); + const addRawFiles = useCallback(async (files: File[], options?: { insertAfterPageId?: string; selectFiles?: boolean; skipAutoUnzip?: boolean }): Promise => { + const stirlingFiles = await addFiles( + { + files, + ...options, + // For direct file uploads: ALWAYS unzip (except HTML ZIPs) + // skipAutoUnzip bypasses preference checks - HTML detection still applies + skipAutoUnzip: true + }, + stateRef, + filesRef, + dispatch, + lifecycleManager, + enablePersistence + ); // Auto-select the newly added files if requested if (options?.selectFiles && stirlingFiles.length > 0) { diff --git a/frontend/src/contexts/file/fileActions.ts b/frontend/src/contexts/file/fileActions.ts index 3f3ec07c7..c1b23d408 100644 --- a/frontend/src/contexts/file/fileActions.ts +++ b/frontend/src/contexts/file/fileActions.ts @@ -18,6 +18,7 @@ import { FileLifecycleManager } from './lifecycle'; import { buildQuickKeySet } from './fileSelectors'; import { StirlingFile } from '../../types/fileContext'; import { fileStorage } from '../../services/fileStorage'; +import { zipFileService } from '../../services/zipFileService'; const DEBUG = process.env.NODE_ENV === 'development'; /** @@ -172,6 +173,11 @@ interface AddFileOptions { // Auto-selection after adding selectFiles?: boolean; + + // Auto-unzip control + autoUnzip?: boolean; + autoUnzipFileLimit?: number; + skipAutoUnzip?: boolean; // When true: always unzip (except HTML). Used for file uploads. When false: respect autoUnzip/autoUnzipFileLimit preferences. Used for tool outputs. } /** @@ -198,7 +204,58 @@ export async function addFiles( const { files = [] } = options; if (DEBUG) console.log(`📄 addFiles(raw): Adding ${files.length} files with immediate thumbnail generation`); + // ZIP pre-processing: Extract ZIP files with configurable behavior + // - File uploads: skipAutoUnzip=true → always extract (except HTML) + // - Tool outputs: skipAutoUnzip=false → respect user preferences + const filesToProcess: File[] = []; + const autoUnzip = options.autoUnzip ?? true; // Default to true + const autoUnzipFileLimit = options.autoUnzipFileLimit ?? 4; // Default limit + const skipAutoUnzip = options.skipAutoUnzip ?? false; + for (const file of files) { + // Check if file is a ZIP + if (zipFileService.isZipFile(file)) { + try { + if (DEBUG) console.log(`📄 addFiles: Detected ZIP file: ${file.name}`); + + // Check if ZIP contains HTML files - if so, keep as ZIP + const containsHtml = await zipFileService.containsHtmlFiles(file); + if (containsHtml) { + if (DEBUG) console.log(`📄 addFiles: ZIP contains HTML, keeping as ZIP: ${file.name}`); + filesToProcess.push(file); + continue; + } + + // Apply extraction with preferences + const extractedFiles = await zipFileService.extractWithPreferences(file, { + autoUnzip, + autoUnzipFileLimit, + skipAutoUnzip + }); + + if (extractedFiles.length === 1 && extractedFiles[0] === file) { + // ZIP was not extracted (over limit or autoUnzip disabled) + if (DEBUG) console.log(`📄 addFiles: ZIP not extracted (preferences): ${file.name}`); + } else { + // ZIP was extracted + if (DEBUG) console.log(`📄 addFiles: Extracted ${extractedFiles.length} files from ZIP: ${file.name}`); + } + + filesToProcess.push(...extractedFiles); + } catch (error) { + console.error(`📄 addFiles: Failed to process ZIP file ${file.name}:`, error); + // On error, keep the ZIP file as-is + filesToProcess.push(file); + } + } else { + // Not a ZIP file, add as-is + filesToProcess.push(file); + } + } + + if (DEBUG) console.log(`📄 addFiles: After ZIP processing, ${filesToProcess.length} files to add`); + + for (const file of filesToProcess) { const quickKey = createQuickKey(file); // Soft deduplication: Check if file already exists by metadata diff --git a/frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts b/frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts index 27a997e2a..a5ef98357 100644 --- a/frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts +++ b/frontend/src/hooks/tools/extractImages/useExtractImagesOperation.ts @@ -1,8 +1,9 @@ +import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useToolOperation, ToolType } from '../shared/useToolOperation'; import { createStandardErrorHandler } from '../../../utils/toolErrorHandler'; import { ExtractImagesParameters, defaultParameters } from './useExtractImagesParameters'; -import JSZip from 'jszip'; +import { useToolResources } from '../shared/useToolResources'; // Static configuration that can be used by both the hook and automation executor export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, file: File): FormData => { @@ -13,39 +14,28 @@ export const buildExtractImagesFormData = (parameters: ExtractImagesParameters, return formData; }; -// Response handler for extract-images which returns a ZIP file -const extractImagesResponseHandler = async (responseData: Blob, _originalFiles: File[]): Promise => { - const zip = new JSZip(); - const zipContent = await zip.loadAsync(responseData); - const extractedFiles: File[] = []; - - for (const [filename, file] of Object.entries(zipContent.files)) { - if (!file.dir) { - const blob = await file.async('blob'); - const extractedFile = new File([blob], filename, { type: blob.type }); - extractedFiles.push(extractedFile); - } - } - - return extractedFiles; -}; - -// Static configuration object +// Static configuration object (without response handler - will be added in hook) export const extractImagesOperationConfig = { toolType: ToolType.singleFile, buildFormData: buildExtractImagesFormData, operationType: 'extractImages', endpoint: '/api/v1/misc/extract-images', defaultParameters, - // Extract-images returns a ZIP file containing multiple image files - responseHandler: extractImagesResponseHandler, } as const; export const useExtractImagesOperation = () => { const { t } = useTranslation(); + const { extractZipFiles } = useToolResources(); + + // Response handler that respects auto-unzip preferences + const responseHandler = useCallback(async (blob: Blob, _originalFiles: File[]): Promise => { + // Extract images returns a ZIP file - use preference-aware extraction + return await extractZipFiles(blob); + }, [extractZipFiles]); return useToolOperation({ ...extractImagesOperationConfig, + responseHandler, getErrorMessage: createStandardErrorHandler(t('extractImages.error.failed', 'An error occurred while extracting images from the PDF.')) }); }; \ No newline at end of file diff --git a/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts index 65b4ba1c0..65f7a4b58 100644 --- a/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts +++ b/frontend/src/hooks/tools/scannerImageSplit/useScannerImageSplitOperation.ts @@ -27,14 +27,14 @@ export const scannerImageSplitOperationConfig = { export const useScannerImageSplitOperation = () => { const { t } = useTranslation(); - const { extractAllZipFiles } = useToolResources(); + const { extractZipFiles } = useToolResources(); // Custom response handler that extracts ZIP files containing images // Can't add to exported config because it requires access to the hook so must be part of the hook const responseHandler = useCallback(async (blob: Blob, originalFiles: File[]): Promise => { try { // Scanner image split returns ZIP files with multiple images - const extractedFiles = await extractAllZipFiles(blob); + const extractedFiles = await extractZipFiles(blob); // If extraction succeeded and returned files, use them if (extractedFiles.length > 0) { @@ -49,7 +49,7 @@ export const useScannerImageSplitOperation = () => { const baseFileName = inputFileName.replace(/\.[^.]+$/, ''); const singleFile = new File([blob], `${baseFileName}.png`, { type: 'image/png' }); return [singleFile]; - }, [extractAllZipFiles]); + }, [extractZipFiles]); const config: ToolOperationConfig = { ...scannerImageSplitOperationConfig, diff --git a/frontend/src/hooks/tools/shared/useToolOperation.ts b/frontend/src/hooks/tools/shared/useToolOperation.ts index d28e5ce77..f5e575a14 100644 --- a/frontend/src/hooks/tools/shared/useToolOperation.ts +++ b/frontend/src/hooks/tools/shared/useToolOperation.ts @@ -151,7 +151,7 @@ export const useToolOperation = ( const { state, actions } = useToolState(); const { actions: fileActions } = useFileContext(); const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls(); - const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources(); + const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles } = useToolResources(); // Track last operation for undo functionality const lastOperationRef = useRef<{ @@ -259,11 +259,6 @@ export const useToolOperation = ( // Default: assume ZIP response for multi-file endpoints // Note: extractZipFiles will check preferences.autoUnzip setting processedFiles = await extractZipFiles(response.data); - - if (processedFiles.length === 0) { - // Try the generic extraction as fallback - processedFiles = await extractAllZipFiles(response.data); - } } // Assume all inputs succeeded together unless server provided an error earlier successSourceIds = validFiles.map(f => (f as any).fileId) as any; @@ -446,7 +441,7 @@ export const useToolOperation = ( actions.setLoading(false); actions.setProgress(null); } - }, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]); + }, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles]); const cancelOperation = useCallback(() => { cancelApiCalls(); diff --git a/frontend/src/hooks/tools/shared/useToolResources.ts b/frontend/src/hooks/tools/shared/useToolResources.ts index 366730885..eb5a3f37d 100644 --- a/frontend/src/hooks/tools/shared/useToolResources.ts +++ b/frontend/src/hooks/tools/shared/useToolResources.ts @@ -27,11 +27,11 @@ export const useToolResources = () => { // Cleanup on unmount - use ref to avoid dependency on blobUrls state const blobUrlsRef = useRef([]); - + useEffect(() => { blobUrlsRef.current = blobUrls; }, [blobUrls]); - + useEffect(() => { return () => { blobUrlsRef.current.forEach(url => { @@ -85,50 +85,17 @@ export const useToolResources = () => { const extractZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { try { - // Check if we should extract based on preferences - const shouldExtract = await zipFileService.shouldUnzip( - zipBlob, - preferences.autoUnzip, - preferences.autoUnzipFileLimit, + return await zipFileService.extractWithPreferences(zipBlob, { + autoUnzip: preferences.autoUnzip, + autoUnzipFileLimit: preferences.autoUnzipFileLimit, skipAutoUnzip - ); - - if (!shouldExtract) { - return [new File([zipBlob], 'result.zip', { type: 'application/zip' })]; - } - - const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); - const extractionResult = await zipFileService.extractPdfFiles(zipFile); - return extractionResult.success ? extractionResult.extractedFiles : []; + }); } catch (error) { console.error('useToolResources.extractZipFiles - Error:', error); return []; } }, [preferences.autoUnzip, preferences.autoUnzipFileLimit]); - const extractAllZipFiles = useCallback(async (zipBlob: Blob, skipAutoUnzip = false): Promise => { - try { - // Check if we should extract based on preferences - const shouldExtract = await zipFileService.shouldUnzip( - zipBlob, - preferences.autoUnzip, - preferences.autoUnzipFileLimit, - skipAutoUnzip - ); - - if (!shouldExtract) { - return [new File([zipBlob], 'result.zip', { type: 'application/zip' })]; - } - - const zipFile = new File([zipBlob], 'temp.zip', { type: 'application/zip' }); - const extractionResult = await zipFileService.extractAllFiles(zipFile); - return extractionResult.success ? extractionResult.extractedFiles : []; - } catch (error) { - console.error('useToolResources.extractAllZipFiles - Error:', error); - return []; - } - }, [preferences.autoUnzip, preferences.autoUnzipFileLimit]); - const createDownloadInfo = useCallback(async ( files: File[], operationType: string @@ -152,7 +119,6 @@ export const useToolResources = () => { generateThumbnailsWithMetadata, createDownloadInfo, extractZipFiles, - extractAllZipFiles, cleanupBlobUrls, }; }; diff --git a/frontend/src/services/zipFileService.ts b/frontend/src/services/zipFileService.ts index 45ec39219..2b5162cad 100644 --- a/frontend/src/services/zipFileService.ts +++ b/frontend/src/services/zipFileService.ts @@ -29,6 +29,7 @@ export interface ZipValidationResult { fileCount: number; totalSizeBytes: number; containsPDFs: boolean; + containsFiles: boolean; errors: string[]; } @@ -42,7 +43,6 @@ export interface ZipExtractionProgress { export class ZipFileService { private readonly maxFileSize = 100 * 1024 * 1024; // 100MB per file private readonly maxTotalSize = 500 * 1024 * 1024; // 500MB total extraction limit - private readonly supportedExtensions = ['.pdf']; // ZIP file validation constants private static readonly VALID_ZIP_TYPES = [ @@ -62,6 +62,7 @@ export class ZipFileService { fileCount: 0, totalSizeBytes: 0, containsPDFs: false, + containsFiles: false, errors: [] }; @@ -115,10 +116,13 @@ export class ZipFileService { result.fileCount = fileCount; result.totalSizeBytes = totalSize; result.containsPDFs = containsPDFs; - result.isValid = result.errors.length === 0 && containsPDFs; + result.containsFiles = fileCount > 0; - if (!containsPDFs) { - result.errors.push('ZIP file does not contain any PDF files'); + // ZIP is valid if it has files and no size errors + result.isValid = result.errors.length === 0 && result.containsFiles; + + if (!result.containsFiles) { + result.errors.push('ZIP file does not contain any files'); } return result; @@ -278,6 +282,37 @@ export class ZipFileService { return filename.toLowerCase().endsWith('.pdf'); } + /** + * Check if a filename indicates an HTML file + */ + private isHtmlFile(filename: string): boolean { + const lowerName = filename.toLowerCase(); + return lowerName.endsWith('.html') || lowerName.endsWith('.htm') || lowerName.endsWith('.xhtml'); + } + + /** + * Check if a ZIP file contains HTML files + * Used to determine if the ZIP should be kept intact (HTML) or extracted (other files) + */ + async containsHtmlFiles(file: Blob | File): Promise { + try { + const zip = new JSZip(); + const zipContents = await zip.loadAsync(file); + + // Check if any file is an HTML file + for (const [filename, zipEntry] of Object.entries(zipContents.files)) { + if (!zipEntry.dir && this.isHtmlFile(filename)) { + return true; + } + } + + return false; + } catch (error) { + console.error('Error checking for HTML files:', error); + return false; + } + } + /** * Validate that a file is actually a PDF by checking its header */ @@ -366,6 +401,62 @@ export class ZipFileService { } } + /** + * Extract files from ZIP with HTML detection and preference checking + * This is the unified method that handles the common pattern of: + * 1. Check for HTML files → keep zipped if present + * 2. Check user preferences → respect autoUnzipFileLimit + * 3. Extract files if appropriate + * + * @param zipBlob - The ZIP blob to process + * @param options - Extraction options + * @returns Array of files (either extracted or the ZIP itself) + */ + async extractWithPreferences( + zipBlob: Blob, + options: { + autoUnzip: boolean; + autoUnzipFileLimit: number; + skipAutoUnzip?: boolean; + } + ): Promise { + try { + // Create File object if not already + const zipFile = zipBlob instanceof File + ? zipBlob + : new File([zipBlob], 'result.zip', { type: 'application/zip' }); + + // Check if ZIP contains HTML files - if so, keep as ZIP + const containsHtml = await this.containsHtmlFiles(zipFile); + if (containsHtml) { + return [zipFile]; + } + + // Check if we should extract based on preferences + const shouldExtract = await this.shouldUnzip( + zipBlob, + options.autoUnzip, + options.autoUnzipFileLimit, + options.skipAutoUnzip || false + ); + + if (!shouldExtract) { + return [zipFile]; + } + + // Extract all files + const extractionResult = await this.extractAllFiles(zipFile); + return extractionResult.success ? extractionResult.extractedFiles : [zipFile]; + } catch (error) { + console.error('Error in extractWithPreferences:', error); + // On error, return ZIP as-is + const zipFile = zipBlob instanceof File + ? zipBlob + : new File([zipBlob], 'result.zip', { type: 'application/zip' }); + return [zipFile]; + } + } + /** * Extract all files from a ZIP archive (not limited to PDFs) */ @@ -486,9 +577,11 @@ export class ZipFileService { } /** - * Extract PDF files from ZIP and store them in IndexedDB with preserved history metadata + * Extract all files from ZIP and store them in IndexedDB with preserved history metadata * Used by both FileManager and FileEditor to avoid code duplication * + * Note: HTML files will NOT be extracted - the ZIP is kept intact when HTML is detected + * * @param zipFile - The ZIP file to extract from * @param zipStub - The StirlingFileStub for the ZIP (contains metadata to preserve) * @returns Object with success status, extracted stubs, and any errors @@ -504,8 +597,15 @@ export class ZipFileService { }; try { - // Extract PDF files from ZIP - const extractionResult = await this.extractPdfFiles(zipFile); + // Check if ZIP contains HTML files - if so, don't extract + const hasHtml = await this.containsHtmlFiles(zipFile); + if (hasHtml) { + result.errors.push('ZIP contains HTML files and will not be auto-extracted. Download the ZIP to access the files.'); + return result; + } + + // Extract all files from ZIP (not just PDFs) + const extractionResult = await this.extractAllFiles(zipFile); if (!extractionResult.success || extractionResult.extractedFiles.length === 0) { result.errors = extractionResult.errors; @@ -515,7 +615,7 @@ export class ZipFileService { // Process each extracted file for (const extractedFile of extractionResult.extractedFiles) { try { - // Generate thumbnail + // Generate thumbnail (works for PDFs and images) const thumbnail = await generateThumbnailForFile(extractedFile); // Create StirlingFile diff --git a/frontend/src/utils/automationFileProcessor.ts b/frontend/src/utils/automationFileProcessor.ts index d81dd3a1b..4b7417177 100644 --- a/frontend/src/utils/automationFileProcessor.ts +++ b/frontend/src/utils/automationFileProcessor.ts @@ -30,6 +30,7 @@ export class AutomationFileProcessor { /** * Extract files from a ZIP blob during automation execution, with fallback for non-ZIP files + * Extracts all file types (PDFs, images, etc.) except HTML files which stay zipped */ static async extractAutomationZipFiles(blob: Blob): Promise { try { @@ -40,20 +41,26 @@ export class AutomationFileProcessor { 'application/zip' ); - const result = await zipFileService.extractPdfFiles(zipFile); - - if (!result.success || result.extractedFiles.length === 0) { - // Fallback: treat as single PDF file - const fallbackFile = ResourceManager.createTimestampedFile( - blob, - AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX, - '.pdf' - ); - + // Check if ZIP contains HTML files - if so, keep as ZIP + const containsHtml = await zipFileService.containsHtmlFiles(zipFile); + if (containsHtml) { + // HTML files should stay zipped - return ZIP as-is return { success: true, - files: [fallbackFile], - errors: [`ZIP extraction failed, treated as single file: ${result.errors?.join(', ') || 'Unknown error'}`] + files: [zipFile], + errors: [] + }; + } + + // Extract all files (not just PDFs) - handles images from scanner-image-split, etc. + const result = await zipFileService.extractAllFiles(zipFile); + + if (!result.success || result.extractedFiles.length === 0) { + // Fallback: keep as ZIP file (might be valid ZIP with extraction issues) + return { + success: true, + files: [zipFile], + errors: [`ZIP extraction failed, kept as ZIP: ${result.errors?.join(', ') || 'Unknown error'}`] }; } @@ -63,18 +70,19 @@ export class AutomationFileProcessor { errors: [] }; } catch (error) { - console.warn('Failed to extract automation ZIP files, falling back to single file:', error); - // Fallback: treat as single PDF file + console.warn('Failed to extract automation ZIP files, keeping as ZIP:', error); + // Fallback: keep as ZIP file for next automation step to handle const fallbackFile = ResourceManager.createTimestampedFile( blob, - AUTOMATION_CONSTANTS.RESULT_FILE_PREFIX, - '.pdf' + AUTOMATION_CONSTANTS.RESPONSE_ZIP_PREFIX, + '.zip', + 'application/zip' ); return { success: true, files: [fallbackFile], - errors: [`ZIP extraction failed, treated as single file: ${error}`] + errors: [`ZIP extraction failed, kept as ZIP: ${error}`] }; } } From 949a16e6eb8a2c1a3b9123a7d589720559225a34 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Wed, 15 Oct 2025 16:03:52 +0100 Subject: [PATCH 4/5] quick Z index fix for file modal (#4686) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Screenshot 2025-10-15 at 3 38 45 PM --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) - [ ] I have performed a self-review of my own code - [ ] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) (if functionality has heavily changed) - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) (for new translation tags only) ### UI Changes (if applicable) - [ ] Screenshots or videos demonstrating the UI changes are attached (e.g., as comments or direct attachments in the PR) ### Testing (if applicable) - [ ] I have tested my changes locally. Refer to the [Testing Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing) for more details. --- frontend/src/components/fileManager/FileListItem.tsx | 2 ++ frontend/src/styles/zIndex.ts | 8 ++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx index 8436d2e29..a721961ec 100644 --- a/frontend/src/components/fileManager/FileListItem.tsx +++ b/frontend/src/components/fileManager/FileListItem.tsx @@ -12,6 +12,7 @@ import { FileId, StirlingFileStub } from '../../types/fileContext'; import { useFileManagerContext } from '../../contexts/FileManagerContext'; import { zipFileService } from '../../services/zipFileService'; import ToolChain from '../shared/ToolChain'; +import { Z_INDEX_OVER_FILE_MANAGER_MODAL } from '../../styles/zIndex'; interface FileListItemProps { file: StirlingFileStub; @@ -127,6 +128,7 @@ const FileListItem: React.FC = ({ withinPortal onOpen={() => setIsMenuOpen(true)} onClose={() => setIsMenuOpen(false)} + zIndex={Z_INDEX_OVER_FILE_MANAGER_MODAL} > Date: Wed, 15 Oct 2025 16:30:51 +0100 Subject: [PATCH 5/5] Hotfix removed UrlSync (#4685) Co-authored-by: Connor Yoh --- frontend/src/contexts/ToolWorkflowContext.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/contexts/ToolWorkflowContext.tsx b/frontend/src/contexts/ToolWorkflowContext.tsx index 378a7bdca..e899f9d35 100644 --- a/frontend/src/contexts/ToolWorkflowContext.tsx +++ b/frontend/src/contexts/ToolWorkflowContext.tsx @@ -9,6 +9,7 @@ import { PageEditorFunctions } from '../types/pageEditor'; import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy'; import { useNavigationActions, useNavigationState } from './NavigationContext'; import { ToolId, isValidToolId } from '../types/toolId'; +import { useNavigationUrlSync } from '../hooks/useUrlSync'; import { getDefaultWorkbench } from '../types/workbench'; import { filterToolRegistryByQuery } from '../utils/toolSearch'; import { useToolHistory } from '../hooks/tools/useUserToolActivity'; @@ -222,6 +223,14 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) { [state.sidebarsVisible, state.readerMode, state.leftPanelView] ); + useNavigationUrlSync( + navigationState.selectedTool, + handleToolSelect, + handleBackToTools, + toolRegistry as ToolRegistry, + true + ); + // Properly memoized context value const contextValue = useMemo((): ToolWorkflowContextValue => ({ // State