-
+
+
+
);
}
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index 71443411f..1cf3581c4 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -103,6 +103,13 @@
--icon-config-bg: #9CA3AF;
--icon-config-color: #FFFFFF;
+ /* Colors for tooltips */
+ --tooltip-title-bg: #DBEFFF;
+ --tooltip-title-color: #31528E;
+ --tooltip-header-bg: #31528E;
+ --tooltip-header-color: white;
+ --tooltip-border: var(--border-default);
+
/* Inactive icon colors for light mode */
--icon-inactive-bg: #9CA3AF;
--icon-inactive-color: #FFFFFF;
@@ -201,6 +208,13 @@
--icon-inactive-bg: #2A2F36;
--icon-inactive-color: #6E7581;
+ /* Dark mode tooltip colors */
+ --tooltip-title-bg: #4B525A;
+ --tooltip-title-color: #fff;
+ --tooltip-header-bg: var(--bg-raised);
+ --tooltip-header-color: var(--text-primary);
+ --tooltip-border: var(--border-default);
+
--accent-interactive: #ffffff;
--text-instruction: #ffffff;
--text-brand: var(--color-gray-800);
@@ -224,6 +238,7 @@
--drop-shadow-color: rgba(255, 255, 255, 0.08);
--drop-shadow-color-strong: rgba(255, 255, 255, 0.04);
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(200, 200, 200, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(200, 200, 200, 0.06)) drop-shadow(0 1.2rem 1rem rgba(200, 200, 200, 0.04));
+
/* Adjust shadows for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
diff --git a/frontend/src/tools/Compress.tsx b/frontend/src/tools/Compress.tsx
index cc0cd5cbc..f4b50b264 100644
--- a/frontend/src/tools/Compress.tsx
+++ b/frontend/src/tools/Compress.tsx
@@ -17,6 +17,7 @@ import CompressSettings from "../components/tools/compress/CompressSettings";
import { useCompressParameters } from "../hooks/tools/compress/useCompressParameters";
import { useCompressOperation } from "../hooks/tools/compress/useCompressOperation";
import { BaseToolProps } from "../types/tool";
+import { CompressTips } from "../components/tooltips/CompressTips";
const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
@@ -25,6 +26,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const compressParams = useCompressParameters();
const compressOperation = useCompressOperation();
+ const compressTips = CompressTips();
// Endpoint validation
const { enabled: endpointEnabled, loading: endpointLoading } = useEndpointEnabled("compress-pdf");
@@ -104,6 +106,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
isCompleted={settingsCollapsed}
onCollapsedClick={settingsCollapsed ? handleSettingsReset : undefined}
completedMessage={settingsCollapsed ? "Compression completed" : undefined}
+ tooltip={compressTips}
>
{
const { t } = useTranslation();
@@ -26,6 +27,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const ocrParams = useOCRParameters();
const ocrOperation = useOCROperation();
+ const ocrTips = OcrTips();
// Step expansion state management
const [expandedStep, setExpandedStep] = useState<'files' | 'settings' | 'advanced' | null>('files');
@@ -126,6 +128,7 @@ const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
setExpandedStep(expandedStep === 'settings' ? null : 'settings');
}}
completedMessage={hasFiles && hasValidSettings && settingsCollapsed ? "Basic settings configured" : undefined}
+ tooltip={ocrTips}
>
;
+ toolPanelRef: React.RefObject;
+}
+
+export interface SidebarInfo {
+ rect: DOMRect | null;
+ isToolPanelActive: boolean;
+ sidebarState: SidebarState;
+}
+
+// Context-related interfaces
+export interface SidebarContextValue {
+ sidebarState: SidebarState;
+ sidebarRefs: SidebarRefs;
+ setSidebarsVisible: React.Dispatch>;
+ setLeftPanelView: React.Dispatch>;
+ setReaderMode: React.Dispatch>;
+}
+
+export interface SidebarProviderProps {
+ children: React.ReactNode;
+}
+
+// QuickAccessBar related interfaces
+export interface QuickAccessBarProps {
+ onToolsClick: () => void;
+ onReaderToggle: () => void;
+}
+
+export interface ButtonConfig {
+ id: string;
+ name: string;
+ icon: React.ReactNode;
+ tooltip: string;
+ isRound?: boolean;
+ size?: 'sm' | 'md' | 'lg' | 'xl';
+ onClick: () => void;
+ type?: 'navigation' | 'modal' | 'action';
+}
diff --git a/frontend/src/types/tips.ts b/frontend/src/types/tips.ts
new file mode 100644
index 000000000..58519e114
--- /dev/null
+++ b/frontend/src/types/tips.ts
@@ -0,0 +1,13 @@
+export interface TooltipContent {
+ header?: {
+ title: string;
+ logo?: string | React.ReactNode;
+ };
+ tips?: Array<{
+ title?: string;
+ description?: string;
+ bullets?: string[];
+ body?: React.ReactNode;
+ }>;
+ content?: React.ReactNode;
+}
\ No newline at end of file
diff --git a/frontend/src/utils/genericUtils.ts b/frontend/src/utils/genericUtils.ts
new file mode 100644
index 000000000..253346292
--- /dev/null
+++ b/frontend/src/utils/genericUtils.ts
@@ -0,0 +1,42 @@
+/**
+ * DOM utility functions for common operations
+ */
+
+/**
+ * Clamps a value between a minimum and maximum
+ * @param value - The value to clamp
+ * @param min - The minimum allowed value
+ * @param max - The maximum allowed value
+ * @returns The clamped value
+ */
+export function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+}
+
+/**
+ * Safely adds an event listener with proper cleanup
+ * @param target - The target element or window/document
+ * @param event - The event type
+ * @param handler - The event handler function
+ * @param options - Event listener options
+ * @returns A cleanup function to remove the listener
+ */
+export function addEventListenerWithCleanup(
+ target: EventTarget,
+ event: string,
+ handler: EventListener,
+ options?: boolean | AddEventListenerOptions
+): () => void {
+ target.addEventListener(event, handler, options);
+ return () => target.removeEventListener(event, handler, options);
+}
+
+/**
+ * Checks if a click event occurred outside of a specified element
+ * @param event - The click event
+ * @param element - The element to check against
+ * @returns True if the click was outside the element
+ */
+export function isClickOutside(event: MouseEvent, element: HTMLElement | null): boolean {
+ return element ? !element.contains(event.target as Node) : true;
+}
diff --git a/frontend/src/utils/sidebarUtils.ts b/frontend/src/utils/sidebarUtils.ts
new file mode 100644
index 000000000..cef144971
--- /dev/null
+++ b/frontend/src/utils/sidebarUtils.ts
@@ -0,0 +1,34 @@
+import { SidebarRefs, SidebarState, SidebarInfo } from '../types/sidebar';
+
+/**
+ * Gets the All tools sidebar information using React refs and state
+ * @param refs - Object containing refs to sidebar elements
+ * @param state - Current sidebar state
+ * @returns Object containing the sidebar rect and whether the tool panel is active
+ */
+export function getSidebarInfo(refs: SidebarRefs, state: SidebarState): SidebarInfo {
+ const { quickAccessRef, toolPanelRef } = refs;
+ const { sidebarsVisible, readerMode } = state;
+
+ // Determine if tool panel should be active based on state
+ const isToolPanelActive = sidebarsVisible && !readerMode;
+
+ let rect: DOMRect | null = null;
+
+ if (isToolPanelActive && toolPanelRef.current) {
+ // Tool panel is expanded: use its rect
+ rect = toolPanelRef.current.getBoundingClientRect();
+ } else if (quickAccessRef.current) {
+ // Fall back to quick access bar
+ // This probably isn't needed but if we ever have tooltips or modals that need to be positioned relative to the quick access bar, we can use this
+ rect = quickAccessRef.current.getBoundingClientRect();
+ }
+
+ return {
+ rect,
+ isToolPanelActive,
+ sidebarState: state
+ };
+}
+
+
\ No newline at end of file
From 7e3321ee16ebb74d282dcccad08fa14e2bc9a762 Mon Sep 17 00:00:00 2001
From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com>
Date: Fri, 8 Aug 2025 15:15:09 +0100
Subject: [PATCH 4/4] Feature/v2/filemanager (#4121)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
FileManager Component Overview
Purpose: Modal component for selecting and managing PDF files with
preview capabilities
Architecture:
- Responsive Layouts: MobileLayout.tsx (stacked) vs DesktopLayout.tsx
(3-column)
- Central State: FileManagerContext handles file operations, selection,
and modal state
- File Storage: IndexedDB persistence with thumbnail caching
Key Components:
- FileSourceButtons: Switch between Recent/Local/Drive sources
- FileListArea: Scrollable file grid with search functionality
- FilePreview: PDF thumbnails with dynamic shadow stacking (1-2 shadow
pages based on file count)
- FileDetails: File info card with metadata
- CompactFileDetails: Mobile-optimized file info layout
File Flow:
1. Users select source → browse/search files → select multiple files →
preview with navigation → open in
tools
2. Files persist across tool switches via FileContext integration
3. Memory management handles large PDFs (up to 100GB+)
```mermaid
graph TD
FM[FileManager] --> ML[MobileLayout]
FM --> DL[DesktopLayout]
ML --> FSB[FileSourceButtons
Recent/Local/Drive]
ML --> FLA[FileListArea]
ML --> FD[FileDetails]
DL --> FSB
DL --> FLA
DL --> FD
FLA --> FLI[FileListItem]
FD --> FP[FilePreview]
FD --> CFD[CompactFileDetails]
```
---------
Co-authored-by: Connor Yoh
---
.../public/locales/en-GB/translation.json | 20 +-
frontend/src/components/FileManager.tsx | 168 ++++++++++++
.../fileManagement/StorageStatsCard.tsx | 92 -------
.../fileManager/CompactFileDetails.tsx | 126 +++++++++
.../components/fileManager/DesktopLayout.tsx | 89 ++++++
.../components/fileManager/DragOverlay.tsx | 44 +++
.../components/fileManager/FileDetails.tsx | 116 ++++++++
.../components/fileManager/FileInfoCard.tsx | 67 +++++
.../components/fileManager/FileListArea.tsx | 80 ++++++
.../components/fileManager/FileListItem.tsx | 84 ++++++
.../components/fileManager/FilePreview.tsx | 156 +++++++++++
.../fileManager/FileSourceButtons.tsx | 103 +++++++
.../fileManager/HiddenFileInput.tsx | 20 ++
.../components/fileManager/MobileLayout.tsx | 83 ++++++
.../components/fileManager/SearchInput.tsx | 33 +++
.../{fileManagement => shared}/FileCard.tsx | 1 +
frontend/src/components/shared/FileGrid.tsx | 2 +-
.../src/components/shared/FileUploadModal.tsx | 36 ---
.../components/shared/FileUploadSelector.tsx | 255 ------------------
frontend/src/contexts/FileContext.tsx | 28 +-
frontend/src/contexts/FileManagerContext.tsx | 218 +++++++++++++++
frontend/src/contexts/FilesModalContext.tsx | 55 +++-
.../tools/convert/useConvertOperation.ts | 5 +-
frontend/src/hooks/useFileManager.ts | 17 +-
frontend/src/hooks/useFilesModal.ts | 57 ----
frontend/src/hooks/useIndexedDBThumbnail.ts | 81 ++++--
frontend/src/pages/HomePage.tsx | 4 +-
frontend/src/services/fileStorage.ts | 26 ++
frontend/src/styles/theme.css | 6 +
.../tests/convert/ConvertIntegration.test.tsx | 55 +++-
.../ConvertSmartDetectionIntegration.test.tsx | 28 +-
frontend/src/utils/fileUtils.ts | 4 +-
frontend/src/utils/thumbnailUtils.ts | 165 +++++++++++-
33 files changed, 1818 insertions(+), 506 deletions(-)
create mode 100644 frontend/src/components/FileManager.tsx
delete mode 100644 frontend/src/components/fileManagement/StorageStatsCard.tsx
create mode 100644 frontend/src/components/fileManager/CompactFileDetails.tsx
create mode 100644 frontend/src/components/fileManager/DesktopLayout.tsx
create mode 100644 frontend/src/components/fileManager/DragOverlay.tsx
create mode 100644 frontend/src/components/fileManager/FileDetails.tsx
create mode 100644 frontend/src/components/fileManager/FileInfoCard.tsx
create mode 100644 frontend/src/components/fileManager/FileListArea.tsx
create mode 100644 frontend/src/components/fileManager/FileListItem.tsx
create mode 100644 frontend/src/components/fileManager/FilePreview.tsx
create mode 100644 frontend/src/components/fileManager/FileSourceButtons.tsx
create mode 100644 frontend/src/components/fileManager/HiddenFileInput.tsx
create mode 100644 frontend/src/components/fileManager/MobileLayout.tsx
create mode 100644 frontend/src/components/fileManager/SearchInput.tsx
rename frontend/src/components/{fileManagement => shared}/FileCard.tsx (99%)
delete mode 100644 frontend/src/components/shared/FileUploadModal.tsx
delete mode 100644 frontend/src/components/shared/FileUploadSelector.tsx
create mode 100644 frontend/src/contexts/FileManagerContext.tsx
delete mode 100644 frontend/src/hooks/useFilesModal.ts
diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 089562ed6..ed3942172 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -1733,7 +1733,25 @@
"storageError": "Storage error occurred",
"storageLow": "Storage is running low. Consider removing old files.",
"supportMessage": "Powered by browser database storage for unlimited capacity",
- "noFileSelected": "No files selected"
+ "noFileSelected": "No files selected",
+ "searchFiles": "Search files...",
+ "recent": "Recent",
+ "localFiles": "Local Files",
+ "googleDrive": "Google Drive",
+ "googleDriveShort": "Drive",
+ "myFiles": "My Files",
+ "noRecentFiles": "No recent files found",
+ "dropFilesHint": "Drop files here to upload",
+ "googleDriveNotAvailable": "Google Drive integration not available",
+ "openFiles": "Open Files",
+ "openFile": "Open File",
+ "details": "File Details",
+ "fileName": "Name",
+ "fileFormat": "Format",
+ "fileSize": "Size",
+ "fileVersion": "Version",
+ "totalSelected": "Total Selected",
+ "dropFilesHere": "Drop files here"
},
"storage": {
"temporaryNotice": "Files are stored temporarily in your browser and may be cleared automatically",
diff --git a/frontend/src/components/FileManager.tsx b/frontend/src/components/FileManager.tsx
new file mode 100644
index 000000000..02f9af5e4
--- /dev/null
+++ b/frontend/src/components/FileManager.tsx
@@ -0,0 +1,168 @@
+import React, { useState, useCallback, useEffect } from 'react';
+import { Modal } from '@mantine/core';
+import { Dropzone } from '@mantine/dropzone';
+import { FileWithUrl } from '../types/file';
+import { useFileManager } from '../hooks/useFileManager';
+import { useFilesModalContext } from '../contexts/FilesModalContext';
+import { Tool } from '../types/tool';
+import MobileLayout from './fileManager/MobileLayout';
+import DesktopLayout from './fileManager/DesktopLayout';
+import DragOverlay from './fileManager/DragOverlay';
+import { FileManagerProvider } from '../contexts/FileManagerContext';
+
+interface FileManagerProps {
+ selectedTool?: Tool | null;
+}
+
+const FileManager: React.FC = ({ selectedTool }) => {
+ const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
+ const [recentFiles, setRecentFiles] = useState([]);
+ const [isDragging, setIsDragging] = useState(false);
+ const [isMobile, setIsMobile] = useState(false);
+
+ const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile } = useFileManager();
+
+ // File management handlers
+ const isFileSupported = useCallback((fileName: string) => {
+ if (!selectedTool?.supportedFormats) return true;
+ const extension = fileName.split('.').pop()?.toLowerCase();
+ return selectedTool.supportedFormats.includes(extension || '');
+ }, [selectedTool?.supportedFormats]);
+
+ const refreshRecentFiles = useCallback(async () => {
+ const files = await loadRecentFiles();
+ setRecentFiles(files);
+ }, [loadRecentFiles]);
+
+ const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
+ try {
+ const fileObjects = await Promise.all(
+ files.map(async (fileWithUrl) => {
+ return await convertToFile(fileWithUrl);
+ })
+ );
+ onFilesSelect(fileObjects);
+ } catch (error) {
+ console.error('Failed to process selected files:', error);
+ }
+ }, [convertToFile, onFilesSelect]);
+
+ const handleNewFileUpload = useCallback(async (files: File[]) => {
+ if (files.length > 0) {
+ try {
+ // Files will get IDs assigned through onFilesSelect -> FileContext addFiles
+ onFilesSelect(files);
+ await refreshRecentFiles();
+ } catch (error) {
+ console.error('Failed to process dropped files:', error);
+ }
+ }
+ }, [onFilesSelect, refreshRecentFiles]);
+
+ const handleRemoveFileByIndex = useCallback(async (index: number) => {
+ await handleRemoveFile(index, recentFiles, setRecentFiles);
+ }, [handleRemoveFile, recentFiles]);
+
+ useEffect(() => {
+ const checkMobile = () => setIsMobile(window.innerWidth < 1030);
+ checkMobile();
+ window.addEventListener('resize', checkMobile);
+ return () => window.removeEventListener('resize', checkMobile);
+ }, []);
+
+ useEffect(() => {
+ if (isFilesModalOpen) {
+ refreshRecentFiles();
+ } else {
+ // Reset state when modal is closed
+ setIsDragging(false);
+ }
+ }, [isFilesModalOpen, refreshRecentFiles]);
+
+ // Cleanup any blob URLs when component unmounts
+ useEffect(() => {
+ return () => {
+ // Clean up blob URLs from recent files
+ recentFiles.forEach(file => {
+ if (file.url && file.url.startsWith('blob:')) {
+ URL.revokeObjectURL(file.url);
+ }
+ });
+ };
+ }, [recentFiles]);
+
+ // Modal size constants for consistent scaling
+ const modalHeight = '80vh';
+ const modalWidth = isMobile ? '100%' : '80vw';
+ const modalMaxWidth = isMobile ? '100%' : '1200px';
+ const modalMaxHeight = '1200px';
+ const modalMinWidth = isMobile ? '320px' : '800px';
+
+ return (
+
+
+ setIsDragging(true)}
+ onDragLeave={() => setIsDragging(false)}
+ accept={["*/*"]}
+ multiple={true}
+ activateOnClick={false}
+ style={{
+ height: '100%',
+ width: '100%',
+ border: 'none',
+ borderRadius: '30px',
+ backgroundColor: 'var(--bg-file-manager)'
+ }}
+ styles={{
+ inner: { pointerEvents: 'all' }
+ }}
+ >
+
+ {isMobile ? : }
+
+
+
+
+
+
+ );
+};
+
+export default FileManager;
\ No newline at end of file
diff --git a/frontend/src/components/fileManagement/StorageStatsCard.tsx b/frontend/src/components/fileManagement/StorageStatsCard.tsx
deleted file mode 100644
index 2d2488712..000000000
--- a/frontend/src/components/fileManagement/StorageStatsCard.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from "react";
-import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
-import { useTranslation } from "react-i18next";
-import StorageIcon from "@mui/icons-material/Storage";
-import DeleteIcon from "@mui/icons-material/Delete";
-import WarningIcon from "@mui/icons-material/Warning";
-import { StorageStats } from "../../services/fileStorage";
-import { formatFileSize } from "../../utils/fileUtils";
-import { getStorageUsagePercent } from "../../utils/storageUtils";
-import { StorageConfig } from "../../types/file";
-
-interface StorageStatsCardProps {
- storageStats: StorageStats | null;
- filesCount: number;
- onClearAll: () => void;
- onReloadFiles: () => void;
- storageConfig: StorageConfig;
-}
-
-const StorageStatsCard = ({
- storageStats,
- filesCount,
- onClearAll,
- onReloadFiles,
- storageConfig,
-}: StorageStatsCardProps) => {
- const { t } = useTranslation();
-
- if (!storageStats) return null;
-
- const storageUsagePercent = getStorageUsagePercent(storageStats);
- const totalUsed = storageStats.totalSize || storageStats.used;
- const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
- const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
-
- return (
-
-
-
-
-
-
- {t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
-
-
-
- {filesCount > 0 && (
- }
- >
- {t("fileManager.clearAll", "Clear All")}
-
- )}
-
-
-
-
-
- );
-};
-
-export default StorageStatsCard;
diff --git a/frontend/src/components/fileManager/CompactFileDetails.tsx b/frontend/src/components/fileManager/CompactFileDetails.tsx
new file mode 100644
index 000000000..7f7c410b7
--- /dev/null
+++ b/frontend/src/components/fileManager/CompactFileDetails.tsx
@@ -0,0 +1,126 @@
+import React from 'react';
+import { Stack, Box, Text, Button, ActionIcon, Center } from '@mantine/core';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { useTranslation } from 'react-i18next';
+import { getFileSize } from '../../utils/fileUtils';
+import { FileWithUrl } from '../../types/file';
+
+interface CompactFileDetailsProps {
+ currentFile: FileWithUrl | null;
+ thumbnail: string | null;
+ selectedFiles: FileWithUrl[];
+ currentFileIndex: number;
+ numberOfFiles: number;
+ isAnimating: boolean;
+ onPrevious: () => void;
+ onNext: () => void;
+ onOpenFiles: () => void;
+}
+
+const CompactFileDetails: React.FC = ({
+ currentFile,
+ thumbnail,
+ selectedFiles,
+ currentFileIndex,
+ numberOfFiles,
+ isAnimating,
+ onPrevious,
+ onNext,
+ onOpenFiles
+}) => {
+ const { t } = useTranslation();
+ const hasSelection = selectedFiles.length > 0;
+ const hasMultipleFiles = numberOfFiles > 1;
+
+ return (
+
+ {/* Compact mobile layout */}
+
+ {/* Small preview */}
+
+ {currentFile && thumbnail ? (
+
+ ) : currentFile ? (
+
+
+
+ ) : null}
+
+
+ {/* File info */}
+
+
+ {currentFile ? currentFile.name : 'No file selected'}
+
+
+ {currentFile ? getFileSize(currentFile) : ''}
+ {selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
+
+ {hasMultipleFiles && (
+
+ {currentFileIndex + 1} of {selectedFiles.length}
+
+ )}
+
+
+ {/* Navigation arrows for multiple files */}
+ {hasMultipleFiles && (
+
+
+
+
+
+
+
+
+ )}
+
+
+ {/* Action Button */}
+
+
+ );
+};
+
+export default CompactFileDetails;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/DesktopLayout.tsx b/frontend/src/components/fileManager/DesktopLayout.tsx
new file mode 100644
index 000000000..be701ff20
--- /dev/null
+++ b/frontend/src/components/fileManager/DesktopLayout.tsx
@@ -0,0 +1,89 @@
+import React from 'react';
+import { Grid } from '@mantine/core';
+import FileSourceButtons from './FileSourceButtons';
+import FileDetails from './FileDetails';
+import SearchInput from './SearchInput';
+import FileListArea from './FileListArea';
+import HiddenFileInput from './HiddenFileInput';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+const DesktopLayout: React.FC = () => {
+ const {
+ activeSource,
+ recentFiles,
+ modalHeight,
+ } = useFileManagerContext();
+
+ return (
+
+ {/* Column 1: File Sources */}
+
+
+
+
+ {/* Column 2: File List */}
+
+
+ {activeSource === 'recent' && (
+
+
+
+ )}
+
+
+ 0 ? modalHeight : '100%',
+ backgroundColor: 'transparent',
+ border: 'none',
+ borderRadius: 0
+ }}
+ />
+
+
+
+
+ {/* Column 3: File Details */}
+
+
+
+
+
+
+ {/* Hidden file input for local file selection */}
+
+
+ );
+};
+
+export default DesktopLayout;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/DragOverlay.tsx b/frontend/src/components/fileManager/DragOverlay.tsx
new file mode 100644
index 000000000..976bb940e
--- /dev/null
+++ b/frontend/src/components/fileManager/DragOverlay.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { Stack, Text, useMantineTheme, alpha } from '@mantine/core';
+import UploadFileIcon from '@mui/icons-material/UploadFile';
+import { useTranslation } from 'react-i18next';
+
+interface DragOverlayProps {
+ isVisible: boolean;
+}
+
+const DragOverlay: React.FC = ({ isVisible }) => {
+ const { t } = useTranslation();
+ const theme = useMantineTheme();
+
+ if (!isVisible) return null;
+
+ return (
+
+
+
+
+ {t('fileManager.dropFilesHere', 'Drop files here to upload')}
+
+
+
+ );
+};
+
+export default DragOverlay;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileDetails.tsx b/frontend/src/components/fileManager/FileDetails.tsx
new file mode 100644
index 000000000..9673d06ad
--- /dev/null
+++ b/frontend/src/components/fileManager/FileDetails.tsx
@@ -0,0 +1,116 @@
+import React, { useState, useEffect } from 'react';
+import { Stack, Button } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { useIndexedDBThumbnail } from '../../hooks/useIndexedDBThumbnail';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+import FilePreview from './FilePreview';
+import FileInfoCard from './FileInfoCard';
+import CompactFileDetails from './CompactFileDetails';
+
+interface FileDetailsProps {
+ compact?: boolean;
+}
+
+const FileDetails: React.FC = ({
+ compact = false
+}) => {
+ const { selectedFiles, onOpenFiles, modalHeight } = useFileManagerContext();
+ const { t } = useTranslation();
+ const [currentFileIndex, setCurrentFileIndex] = useState(0);
+ const [isAnimating, setIsAnimating] = useState(false);
+
+ // Get the currently displayed file
+ const currentFile = selectedFiles.length > 0 ? selectedFiles[currentFileIndex] : null;
+ const hasSelection = selectedFiles.length > 0;
+ const hasMultipleFiles = selectedFiles.length > 1;
+
+ // Use IndexedDB hook for the current file
+ const { thumbnail: currentThumbnail } = useIndexedDBThumbnail(currentFile);
+
+ // Get thumbnail for current file
+ const getCurrentThumbnail = () => {
+ return currentThumbnail;
+ };
+
+ const handlePrevious = () => {
+ if (isAnimating) return;
+ setIsAnimating(true);
+ setTimeout(() => {
+ setCurrentFileIndex(prev => prev > 0 ? prev - 1 : selectedFiles.length - 1);
+ setIsAnimating(false);
+ }, 150);
+ };
+
+ const handleNext = () => {
+ if (isAnimating) return;
+ setIsAnimating(true);
+ setTimeout(() => {
+ setCurrentFileIndex(prev => prev < selectedFiles.length - 1 ? prev + 1 : 0);
+ setIsAnimating(false);
+ }, 150);
+ };
+
+ // Reset index when selection changes
+ React.useEffect(() => {
+ if (currentFileIndex >= selectedFiles.length) {
+ setCurrentFileIndex(0);
+ }
+ }, [selectedFiles.length, currentFileIndex]);
+
+ if (compact) {
+ return (
+
+ );
+ }
+
+ return (
+
+ {/* Section 1: Thumbnail Preview */}
+
+
+ {/* Section 2: File Details */}
+
+
+
+
+ );
+};
+
+export default FileDetails;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileInfoCard.tsx b/frontend/src/components/fileManager/FileInfoCard.tsx
new file mode 100644
index 000000000..7e69dd2ed
--- /dev/null
+++ b/frontend/src/components/fileManager/FileInfoCard.tsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import { Stack, Card, Box, Text, Badge, Group, Divider, ScrollArea } from '@mantine/core';
+import { useTranslation } from 'react-i18next';
+import { detectFileExtension, getFileSize } from '../../utils/fileUtils';
+import { FileWithUrl } from '../../types/file';
+
+interface FileInfoCardProps {
+ currentFile: FileWithUrl | null;
+ modalHeight: string;
+}
+
+const FileInfoCard: React.FC = ({
+ currentFile,
+ modalHeight
+}) => {
+ const { t } = useTranslation();
+
+ return (
+
+
+
+ {t('fileManager.details', 'File Details')}
+
+
+
+
+
+ {t('fileManager.fileName', 'Name')}
+
+ {currentFile ? currentFile.name : ''}
+
+
+
+
+
+ {t('fileManager.fileFormat', 'Format')}
+ {currentFile ? (
+
+ {detectFileExtension(currentFile.name).toUpperCase()}
+
+ ) : (
+
+ )}
+
+
+
+
+ {t('fileManager.fileSize', 'Size')}
+
+ {currentFile ? getFileSize(currentFile) : ''}
+
+
+
+
+
+ {t('fileManager.fileVersion', 'Version')}
+
+ {currentFile ? '1.0' : ''}
+
+
+
+
+
+ );
+};
+
+export default FileInfoCard;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileListArea.tsx b/frontend/src/components/fileManager/FileListArea.tsx
new file mode 100644
index 000000000..8e1975137
--- /dev/null
+++ b/frontend/src/components/fileManager/FileListArea.tsx
@@ -0,0 +1,80 @@
+import React from 'react';
+import { Center, ScrollArea, Text, Stack } from '@mantine/core';
+import CloudIcon from '@mui/icons-material/Cloud';
+import HistoryIcon from '@mui/icons-material/History';
+import { useTranslation } from 'react-i18next';
+import FileListItem from './FileListItem';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+interface FileListAreaProps {
+ scrollAreaHeight: string;
+ scrollAreaStyle?: React.CSSProperties;
+}
+
+const FileListArea: React.FC = ({
+ scrollAreaHeight,
+ scrollAreaStyle = {},
+}) => {
+ const {
+ activeSource,
+ recentFiles,
+ filteredFiles,
+ selectedFileIds,
+ onFileSelect,
+ onFileRemove,
+ onFileDoubleClick,
+ isFileSupported,
+ } = useFileManagerContext();
+ const { t } = useTranslation();
+
+ if (activeSource === 'recent') {
+ return (
+
+
+ {recentFiles.length === 0 ? (
+
+
+
+ {t('fileManager.noRecentFiles', 'No recent files')}
+
+ {t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
+
+
+
+ ) : (
+ filteredFiles.map((file, index) => (
+ onFileSelect(file)}
+ onRemove={() => onFileRemove(index)}
+ onDoubleClick={() => onFileDoubleClick(file)}
+ />
+ ))
+ )}
+
+
+ );
+ }
+
+ // Google Drive placeholder
+ return (
+
+
+
+ {t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}
+
+
+ );
+};
+
+export default FileListArea;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileListItem.tsx b/frontend/src/components/fileManager/FileListItem.tsx
new file mode 100644
index 000000000..147133009
--- /dev/null
+++ b/frontend/src/components/fileManager/FileListItem.tsx
@@ -0,0 +1,84 @@
+import React, { useState } from 'react';
+import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { getFileSize, getFileDate } from '../../utils/fileUtils';
+import { FileWithUrl } from '../../types/file';
+
+interface FileListItemProps {
+ file: FileWithUrl;
+ isSelected: boolean;
+ isSupported: boolean;
+ onSelect: () => void;
+ onRemove: () => void;
+ onDoubleClick?: () => void;
+ isLast?: boolean;
+}
+
+const FileListItem: React.FC = ({
+ file,
+ isSelected,
+ isSupported,
+ onSelect,
+ onRemove,
+ onDoubleClick
+}) => {
+ const [isHovered, setIsHovered] = useState(false);
+
+ return (
+ <>
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+
+
+ {}} // Handled by parent onClick
+ size="sm"
+ pl="sm"
+ pr="xs"
+ styles={{
+ input: {
+ cursor: 'pointer'
+ }
+ }}
+ />
+
+
+
+ {file.name}
+ {getFileSize(file)} • {getFileDate(file)}
+
+ {/* Delete button - fades in/out on hover */}
+ { e.stopPropagation(); onRemove(); }}
+ style={{
+ opacity: isHovered ? 1 : 0,
+ transform: isHovered ? 'scale(1)' : 'scale(0.8)',
+ transition: 'opacity 0.3s ease, transform 0.3s ease',
+ pointerEvents: isHovered ? 'auto' : 'none'
+ }}
+ >
+
+
+
+
+ { }
+ >
+ );
+};
+
+export default FileListItem;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FilePreview.tsx b/frontend/src/components/fileManager/FilePreview.tsx
new file mode 100644
index 000000000..deb4cc67b
--- /dev/null
+++ b/frontend/src/components/fileManager/FilePreview.tsx
@@ -0,0 +1,156 @@
+import React from 'react';
+import { Box, Center, ActionIcon, Image } from '@mantine/core';
+import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
+import ChevronLeftIcon from '@mui/icons-material/ChevronLeft';
+import ChevronRightIcon from '@mui/icons-material/ChevronRight';
+import { FileWithUrl } from '../../types/file';
+
+interface FilePreviewProps {
+ currentFile: FileWithUrl | null;
+ thumbnail: string | null;
+ numberOfFiles: number;
+ isAnimating: boolean;
+ modalHeight: string;
+ onPrevious: () => void;
+ onNext: () => void;
+}
+
+const FilePreview: React.FC = ({
+ currentFile,
+ thumbnail,
+ numberOfFiles,
+ isAnimating,
+ modalHeight,
+ onPrevious,
+ onNext
+}) => {
+ const hasMultipleFiles = numberOfFiles > 1;
+ // Common style objects
+ const navigationArrowStyle = {
+ position: 'absolute' as const,
+ top: '50%',
+ transform: 'translateY(-50%)',
+ zIndex: 10
+ };
+
+ const stackDocumentBaseStyle = {
+ position: 'absolute' as const,
+ width: '100%',
+ height: '100%'
+ };
+
+ const animationStyle = {
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
+ transform: isAnimating ? 'scale(0.95) translateX(1.25rem)' : 'scale(1) translateX(0)',
+ opacity: isAnimating ? 0.7 : 1
+ };
+
+ const mainDocumentShadow = '0 6px 16px rgba(0, 0, 0, 0.2)';
+ const stackDocumentShadows = {
+ back: '0 2px 8px rgba(0, 0, 0, 0.1)',
+ middle: '0 3px 10px rgba(0, 0, 0, 0.12)'
+ };
+
+ return (
+
+
+ {/* Left Navigation Arrow */}
+ {hasMultipleFiles && (
+
+
+
+ )}
+
+ {/* Document Stack Container */}
+
+ {/* Background documents (stack effect) */}
+ {/* Show 2 shadow pages for 3+ files */}
+ {numberOfFiles >= 3 && (
+
+ )}
+
+ {/* Show 1 shadow page for 2+ files */}
+ {numberOfFiles >= 2 && (
+
+ )}
+
+ {/* Main document */}
+ {currentFile && thumbnail ? (
+
+ ) : currentFile ? (
+
+
+
+ ) : null}
+
+
+ {/* Right Navigation Arrow */}
+ {hasMultipleFiles && (
+
+
+
+ )}
+
+
+ );
+};
+
+export default FilePreview;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/FileSourceButtons.tsx b/frontend/src/components/fileManager/FileSourceButtons.tsx
new file mode 100644
index 000000000..a6870a661
--- /dev/null
+++ b/frontend/src/components/fileManager/FileSourceButtons.tsx
@@ -0,0 +1,103 @@
+import React from 'react';
+import { Stack, Text, Button, Group } from '@mantine/core';
+import HistoryIcon from '@mui/icons-material/History';
+import FolderIcon from '@mui/icons-material/Folder';
+import CloudIcon from '@mui/icons-material/Cloud';
+import { useTranslation } from 'react-i18next';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+interface FileSourceButtonsProps {
+ horizontal?: boolean;
+}
+
+const FileSourceButtons: React.FC = ({
+ horizontal = false
+}) => {
+ const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
+ const { t } = useTranslation();
+
+ const buttonProps = {
+ variant: (source: string) => activeSource === source ? 'filled' : 'subtle',
+ getColor: (source: string) => activeSource === source ? 'var(--mantine-color-gray-2)' : undefined,
+ getStyles: (source: string) => ({
+ root: {
+ backgroundColor: activeSource === source ? undefined : 'transparent',
+ color: activeSource === source ? 'var(--mantine-color-gray-9)' : 'var(--mantine-color-gray-6)',
+ border: 'none',
+ '&:hover': {
+ backgroundColor: activeSource === source ? undefined : 'var(--mantine-color-gray-0)'
+ }
+ }
+ })
+ };
+
+ const buttons = (
+ <>
+ }
+ justify={horizontal ? "center" : "flex-start"}
+ onClick={() => onSourceChange('recent')}
+ fullWidth={!horizontal}
+ size={horizontal ? "xs" : "sm"}
+ color={buttonProps.getColor('recent')}
+ styles={buttonProps.getStyles('recent')}
+ >
+ {horizontal ? t('fileManager.recent', 'Recent') : t('fileManager.recent', 'Recent')}
+
+
+ }
+ justify={horizontal ? "center" : "flex-start"}
+ onClick={onLocalFileClick}
+ fullWidth={!horizontal}
+ size={horizontal ? "xs" : "sm"}
+ styles={{
+ root: {
+ backgroundColor: 'transparent',
+ border: 'none',
+ '&:hover': {
+ backgroundColor: 'var(--mantine-color-gray-0)'
+ }
+ }
+ }}
+ >
+ {horizontal ? t('fileManager.localFiles', 'Local') : t('fileManager.localFiles', 'Local Files')}
+
+
+ }
+ justify={horizontal ? "center" : "flex-start"}
+ onClick={() => onSourceChange('drive')}
+ fullWidth={!horizontal}
+ size={horizontal ? "xs" : "sm"}
+ disabled
+ color={activeSource === 'drive' ? 'gray' : undefined}
+ styles={buttonProps.getStyles('drive')}
+ >
+ {horizontal ? t('fileManager.googleDriveShort', 'Drive') : t('fileManager.googleDrive', 'Google Drive')}
+
+ >
+ );
+
+ if (horizontal) {
+ return (
+
+ {buttons}
+
+ );
+ }
+
+ return (
+
+
+ {t('fileManager.myFiles', 'My Files')}
+
+ {buttons}
+
+ );
+};
+
+export default FileSourceButtons;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/HiddenFileInput.tsx b/frontend/src/components/fileManager/HiddenFileInput.tsx
new file mode 100644
index 000000000..6f2834267
--- /dev/null
+++ b/frontend/src/components/fileManager/HiddenFileInput.tsx
@@ -0,0 +1,20 @@
+import React from 'react';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+const HiddenFileInput: React.FC = () => {
+ const { fileInputRef, onFileInputChange } = useFileManagerContext();
+
+ return (
+
+ );
+};
+
+export default HiddenFileInput;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/MobileLayout.tsx b/frontend/src/components/fileManager/MobileLayout.tsx
new file mode 100644
index 000000000..30d1ad6b9
--- /dev/null
+++ b/frontend/src/components/fileManager/MobileLayout.tsx
@@ -0,0 +1,83 @@
+import React from 'react';
+import { Stack, Box } from '@mantine/core';
+import FileSourceButtons from './FileSourceButtons';
+import FileDetails from './FileDetails';
+import SearchInput from './SearchInput';
+import FileListArea from './FileListArea';
+import HiddenFileInput from './HiddenFileInput';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+const MobileLayout: React.FC = () => {
+ const {
+ activeSource,
+ selectedFiles,
+ modalHeight,
+ } = useFileManagerContext();
+
+ // Calculate the height more accurately based on actual content
+ const calculateFileListHeight = () => {
+ // Base modal height minus padding and gaps
+ const baseHeight = `calc(${modalHeight} - 2rem)`; // Account for Stack padding
+
+ // Estimate heights of fixed components
+ const fileSourceHeight = '3rem'; // FileSourceButtons height
+ const fileDetailsHeight = selectedFiles.length > 0 ? '10rem' : '8rem'; // FileDetails compact height
+ const searchHeight = activeSource === 'recent' ? '3rem' : '0rem'; // SearchInput height
+ const gapHeight = activeSource === 'recent' ? '3rem' : '2rem'; // Stack gaps
+
+ return `calc(${baseHeight} - ${fileSourceHeight} - ${fileDetailsHeight} - ${searchHeight} - ${gapHeight})`;
+ };
+
+ return (
+
+ {/* Section 1: File Sources - Fixed at top */}
+
+
+
+
+
+
+
+
+ {/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
+
+ {activeSource === 'recent' && (
+
+
+
+ )}
+
+
+
+
+
+
+ {/* Hidden file input for local file selection */}
+
+
+ );
+};
+
+export default MobileLayout;
\ No newline at end of file
diff --git a/frontend/src/components/fileManager/SearchInput.tsx b/frontend/src/components/fileManager/SearchInput.tsx
new file mode 100644
index 000000000..f47da0dca
--- /dev/null
+++ b/frontend/src/components/fileManager/SearchInput.tsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { TextInput } from '@mantine/core';
+import SearchIcon from '@mui/icons-material/Search';
+import { useTranslation } from 'react-i18next';
+import { useFileManagerContext } from '../../contexts/FileManagerContext';
+
+interface SearchInputProps {
+ style?: React.CSSProperties;
+}
+
+const SearchInput: React.FC = ({ style }) => {
+ const { t } = useTranslation();
+ const { searchTerm, onSearchChange } = useFileManagerContext();
+
+ return (
+ }
+ value={searchTerm}
+ onChange={(e) => onSearchChange(e.target.value)}
+
+ style={{ padding: '0.5rem', ...style }}
+ styles={{
+ input: {
+ border: 'none',
+ backgroundColor: 'transparent'
+ }
+ }}
+ />
+ );
+};
+
+export default SearchInput;
\ No newline at end of file
diff --git a/frontend/src/components/fileManagement/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx
similarity index 99%
rename from frontend/src/components/fileManagement/FileCard.tsx
rename to frontend/src/components/shared/FileCard.tsx
index d474a2f63..1b686ddaf 100644
--- a/frontend/src/components/fileManagement/FileCard.tsx
+++ b/frontend/src/components/shared/FileCard.tsx
@@ -6,6 +6,7 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
+import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { fileStorage } from "../../services/fileStorage";
diff --git a/frontend/src/components/shared/FileGrid.tsx b/frontend/src/components/shared/FileGrid.tsx
index 78b5a8f17..791a8a453 100644
--- a/frontend/src/components/shared/FileGrid.tsx
+++ b/frontend/src/components/shared/FileGrid.tsx
@@ -3,7 +3,7 @@ import { Box, Flex, Group, Text, Button, TextInput, Select, Badge } from "@manti
import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
-import FileCard from "../fileManagement/FileCard";
+import FileCard from "./FileCard";
import { FileWithUrl } from "../../types/file";
interface FileGridProps {
diff --git a/frontend/src/components/shared/FileUploadModal.tsx b/frontend/src/components/shared/FileUploadModal.tsx
deleted file mode 100644
index a83e96e62..000000000
--- a/frontend/src/components/shared/FileUploadModal.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import React from 'react';
-import { Modal } from '@mantine/core';
-import FileUploadSelector from './FileUploadSelector';
-import { useFilesModalContext } from '../../contexts/FilesModalContext';
-import { Tool } from '../../types/tool';
-
-interface FileUploadModalProps {
- selectedTool?: Tool | null;
-}
-
-const FileUploadModal: React.FC = ({ selectedTool }) => {
- const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
-
-
- return (
-
-
-
- );
-};
-
-export default FileUploadModal;
\ No newline at end of file
diff --git a/frontend/src/components/shared/FileUploadSelector.tsx b/frontend/src/components/shared/FileUploadSelector.tsx
deleted file mode 100644
index 3f345f24b..000000000
--- a/frontend/src/components/shared/FileUploadSelector.tsx
+++ /dev/null
@@ -1,255 +0,0 @@
-import React, { useState, useCallback, useRef, useEffect } from 'react';
-import { Stack, Button, Text, Center, Box, Divider } from '@mantine/core';
-import { Dropzone } from '@mantine/dropzone';
-import UploadFileIcon from '@mui/icons-material/UploadFile';
-import { useTranslation } from 'react-i18next';
-import { fileStorage } from '../../services/fileStorage';
-import { FileWithUrl } from '../../types/file';
-import { detectFileExtension } from '../../utils/fileUtils';
-import FileGrid from './FileGrid';
-import MultiSelectControls from './MultiSelectControls';
-import { useFileManager } from '../../hooks/useFileManager';
-
-interface FileUploadSelectorProps {
- // Appearance
- title?: string;
- subtitle?: string;
- showDropzone?: boolean;
-
- // File handling
- sharedFiles?: any[];
- onFileSelect?: (file: File) => void;
- onFilesSelect: (files: File[]) => void;
- accept?: string[];
- supportedExtensions?: string[]; // Extensions this tool supports (e.g., ['pdf', 'jpg', 'png'])
-
- // Loading state
- loading?: boolean;
- disabled?: boolean;
-
- // Recent files
- showRecentFiles?: boolean;
- maxRecentFiles?: number;
-}
-
-const FileUploadSelector = ({
- title,
- subtitle,
- showDropzone = true,
- sharedFiles = [],
- onFileSelect,
- onFilesSelect,
- accept = ["application/pdf", "application/zip", "application/x-zip-compressed"],
- supportedExtensions = ["pdf"], // Default to PDF only for most tools
- loading = false,
- disabled = false,
- showRecentFiles = true,
- maxRecentFiles = 8,
-}: FileUploadSelectorProps) => {
- const { t } = useTranslation();
- const fileInputRef = useRef(null);
-
- const [recentFiles, setRecentFiles] = useState([]);
- const [selectedFiles, setSelectedFiles] = useState([]);
-
- const { loadRecentFiles, handleRemoveFile, storeFile, convertToFile, createFileSelectionHandlers } = useFileManager();
-
- // Utility function to check if a file extension is supported
- const isFileSupported = useCallback((fileName: string): boolean => {
- const extension = detectFileExtension(fileName);
- return extension ? supportedExtensions.includes(extension) : false;
- }, [supportedExtensions]);
-
- const refreshRecentFiles = useCallback(async () => {
- const files = await loadRecentFiles();
- setRecentFiles(files);
- }, [loadRecentFiles]);
-
- const handleNewFileUpload = useCallback(async (uploadedFiles: File[]) => {
- if (uploadedFiles.length === 0) return;
-
- if (showRecentFiles) {
- try {
- for (const file of uploadedFiles) {
- await storeFile(file);
- }
- refreshRecentFiles();
- } catch (error) {
- console.error('Failed to save files to recent:', error);
- }
- }
-
- if (onFilesSelect) {
- onFilesSelect(uploadedFiles);
- } else if (onFileSelect) {
- onFileSelect(uploadedFiles[0]);
- }
- }, [onFileSelect, onFilesSelect, showRecentFiles, storeFile, refreshRecentFiles]);
-
- const handleFileInputChange = useCallback((event: React.ChangeEvent) => {
- const files = event.target.files;
- if (files && files.length > 0) {
- const fileArray = Array.from(files);
- console.log('File input change:', fileArray.length, 'files');
- handleNewFileUpload(fileArray);
- }
- if (fileInputRef.current) {
- fileInputRef.current.value = '';
- }
- }, [handleNewFileUpload]);
-
- const openFileDialog = useCallback(() => {
- fileInputRef.current?.click();
- }, []);
-
- const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
- try {
- const fileObj = await convertToFile(file);
- if (onFilesSelect) {
- onFilesSelect([fileObj]);
- } else if (onFileSelect) {
- onFileSelect(fileObj);
- }
- } catch (error) {
- console.error('Failed to load file from recent:', error);
- }
- }, [onFileSelect, onFilesSelect, convertToFile]);
-
- const selectionHandlers = createFileSelectionHandlers(selectedFiles, setSelectedFiles);
-
- const handleSelectedRecentFiles = useCallback(async () => {
- if (onFilesSelect) {
- await selectionHandlers.selectMultipleFiles(recentFiles, onFilesSelect);
- }
- }, [recentFiles, onFilesSelect, selectionHandlers]);
-
- const handleRemoveFileByIndex = useCallback(async (index: number) => {
- await handleRemoveFile(index, recentFiles, setRecentFiles);
- const file = recentFiles[index];
- setSelectedFiles(prev => prev.filter(id => id !== (file.id || file.name)));
- }, [handleRemoveFile, recentFiles]);
-
- useEffect(() => {
- if (showRecentFiles) {
- refreshRecentFiles();
- }
- }, [showRecentFiles, refreshRecentFiles]);
-
- // Get default title and subtitle from translations if not provided
- const displayTitle = title || t("fileUpload.selectFiles", "Select files");
- const displaySubtitle = subtitle || t("fileUpload.chooseFromStorageMultiple", "Choose files from storage or upload new PDFs");
-
- return (
- <>
-
- {/* Title and description */}
-
-
-
- {displayTitle}
-
-
- {displaySubtitle}
-
-
-
- {/* Action buttons */}
-
-
- {showDropzone ? (
-
-
-
-
- {t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
-
-
- {accept.includes('application/pdf') && accept.includes('application/zip')
- ? t("fileUpload.pdfAndZipFiles", "PDF and ZIP files")
- : accept.includes('application/pdf')
- ? t("fileUpload.pdfFilesOnly", "PDF files only")
- : t("fileUpload.supportedFileTypes", "Supported file types")
- }
-
-
-
-
- ) : (
-
-
-
- {/* Manual file input as backup */}
-
-
- )}
-
-
- {/* Recent Files Section */}
- {showRecentFiles && recentFiles.length > 0 && (
-
-
-
- {t("fileUpload.recentFiles", "Recent Files")}
-
- {
- await Promise.all(recentFiles.map(async (file) => {
- await fileStorage.deleteFile(file.id || file.name);
- }));
- setRecentFiles([]);
- setSelectedFiles([]);
- }}
- />
-
- {
- await Promise.all(recentFiles.map(async (file) => {
- await fileStorage.deleteFile(file.id || file.name);
- }));
- setRecentFiles([]);
- setSelectedFiles([]);
- }}
- />
-
- )}
-
- >
- );
-};
-
-export default FileUploadSelector;
diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx
index 6e8a42fab..f84d2ec8b 100644
--- a/frontend/src/contexts/FileContext.tsx
+++ b/frontend/src/contexts/FileContext.tsx
@@ -100,7 +100,7 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
case 'REMOVE_FILES':
const remainingFiles = state.activeFiles.filter(file => {
const fileId = getFileId(file);
- return !action.payload.includes(fileId);
+ return !fileId || !action.payload.includes(fileId);
});
const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
return {
@@ -491,26 +491,38 @@ export function FileContextProvider({
}, [cleanupFile]);
// Action implementations
- const addFiles = useCallback(async (files: File[]) => {
+ const addFiles = useCallback(async (files: File[]): Promise => {
dispatch({ type: 'ADD_FILES', payload: files });
// Auto-save to IndexedDB if persistence enabled
if (enablePersistence) {
for (const file of files) {
try {
- // Check if file already has an ID (already in IndexedDB)
+ // Check if file already has an explicit ID property (already in IndexedDB)
const fileId = getFileId(file);
if (!fileId) {
- // File doesn't have ID, store it and get the ID
- const storedFile = await fileStorage.storeFile(file);
- // Add the ID to the file object
- Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
+ // File doesn't have explicit ID, store it with thumbnail
+ try {
+ // Generate thumbnail for better recent files experience
+ const thumbnail = await thumbnailGenerationService.generateThumbnail(file);
+ const storedFile = await fileStorage.storeFile(file, thumbnail);
+ // Add the ID to the file object
+ Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
+ } catch (thumbnailError) {
+ // If thumbnail generation fails, store without thumbnail
+ console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
+ const storedFile = await fileStorage.storeFile(file);
+ Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
+ }
}
} catch (error) {
console.error('Failed to store file:', error);
}
}
}
+
+ // Return files with their IDs assigned
+ return files;
}, [enablePersistence]);
const removeFiles = useCallback((fileIds: string[], deleteFromStorage: boolean = true) => {
@@ -682,7 +694,7 @@ export function FileContextProvider({
const getFileById = useCallback((fileId: string): File | undefined => {
return state.activeFiles.find(file => {
const actualFileId = getFileId(file);
- return actualFileId === fileId;
+ return actualFileId && actualFileId === fileId;
});
}, [state.activeFiles]);
diff --git a/frontend/src/contexts/FileManagerContext.tsx b/frontend/src/contexts/FileManagerContext.tsx
new file mode 100644
index 000000000..c7f924e8e
--- /dev/null
+++ b/frontend/src/contexts/FileManagerContext.tsx
@@ -0,0 +1,218 @@
+import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
+import { FileWithUrl } from '../types/file';
+import { StoredFile } from '../services/fileStorage';
+
+// Type for the context value - now contains everything directly
+interface FileManagerContextValue {
+ // State
+ activeSource: 'recent' | 'local' | 'drive';
+ selectedFileIds: string[];
+ searchTerm: string;
+ selectedFiles: FileWithUrl[];
+ filteredFiles: FileWithUrl[];
+ fileInputRef: React.RefObject;
+
+ // Handlers
+ onSourceChange: (source: 'recent' | 'local' | 'drive') => void;
+ onLocalFileClick: () => void;
+ onFileSelect: (file: FileWithUrl) => void;
+ onFileRemove: (index: number) => void;
+ onFileDoubleClick: (file: FileWithUrl) => void;
+ onOpenFiles: () => void;
+ onSearchChange: (value: string) => void;
+ onFileInputChange: (event: React.ChangeEvent) => void;
+
+ // External props
+ recentFiles: FileWithUrl[];
+ isFileSupported: (fileName: string) => boolean;
+ modalHeight: string;
+}
+
+// Create the context
+const FileManagerContext = createContext(null);
+
+// Provider component props
+interface FileManagerProviderProps {
+ children: React.ReactNode;
+ recentFiles: FileWithUrl[];
+ onFilesSelected: (files: FileWithUrl[]) => void;
+ onClose: () => void;
+ isFileSupported: (fileName: string) => boolean;
+ isOpen: boolean;
+ onFileRemove: (index: number) => void;
+ modalHeight: string;
+ storeFile: (file: File) => Promise;
+ refreshRecentFiles: () => Promise;
+}
+
+export const FileManagerProvider: React.FC = ({
+ children,
+ recentFiles,
+ onFilesSelected,
+ onClose,
+ isFileSupported,
+ isOpen,
+ onFileRemove,
+ modalHeight,
+ storeFile,
+ refreshRecentFiles,
+}) => {
+ const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
+ const [selectedFileIds, setSelectedFileIds] = useState([]);
+ const [searchTerm, setSearchTerm] = useState('');
+ const fileInputRef = useRef(null);
+
+ // Track blob URLs for cleanup
+ const createdBlobUrls = useRef>(new Set());
+
+ // Computed values (with null safety)
+ const selectedFiles = (recentFiles || []).filter(file => selectedFileIds.includes(file.id || file.name));
+ const filteredFiles = (recentFiles || []).filter(file =>
+ file.name.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ const handleSourceChange = useCallback((source: 'recent' | 'local' | 'drive') => {
+ setActiveSource(source);
+ if (source !== 'recent') {
+ setSelectedFileIds([]);
+ setSearchTerm('');
+ }
+ }, []);
+
+ const handleLocalFileClick = useCallback(() => {
+ fileInputRef.current?.click();
+ }, []);
+
+ const handleFileSelect = useCallback((file: FileWithUrl) => {
+ setSelectedFileIds(prev => {
+ if (prev.includes(file.id)) {
+ return prev.filter(id => id !== file.id);
+ } else {
+ return [...prev, file.id];
+ }
+ });
+ }, []);
+
+ const handleFileRemove = useCallback((index: number) => {
+ const fileToRemove = filteredFiles[index];
+ if (fileToRemove) {
+ setSelectedFileIds(prev => prev.filter(id => id !== fileToRemove.id));
+ }
+ onFileRemove(index);
+ }, [filteredFiles, onFileRemove]);
+
+ const handleFileDoubleClick = useCallback((file: FileWithUrl) => {
+ if (isFileSupported(file.name)) {
+ onFilesSelected([file]);
+ onClose();
+ }
+ }, [isFileSupported, onFilesSelected, onClose]);
+
+ const handleOpenFiles = useCallback(() => {
+ if (selectedFiles.length > 0) {
+ onFilesSelected(selectedFiles);
+ onClose();
+ }
+ }, [selectedFiles, onFilesSelected, onClose]);
+
+ const handleSearchChange = useCallback((value: string) => {
+ setSearchTerm(value);
+ }, []);
+
+ const handleFileInputChange = useCallback(async (event: React.ChangeEvent) => {
+ const files = Array.from(event.target.files || []);
+ if (files.length > 0) {
+ try {
+ // Create FileWithUrl objects - FileContext will handle storage and ID assignment
+ const fileWithUrls = files.map(file => {
+ const url = URL.createObjectURL(file);
+ createdBlobUrls.current.add(url);
+
+ return {
+ // No ID assigned here - FileContext will handle storage and ID assignment
+ name: file.name,
+ file,
+ url,
+ size: file.size,
+ lastModified: file.lastModified,
+ };
+ });
+
+ onFilesSelected(fileWithUrls);
+ await refreshRecentFiles();
+ onClose();
+ } catch (error) {
+ console.error('Failed to process selected files:', error);
+ }
+ }
+ event.target.value = '';
+ }, [storeFile, onFilesSelected, refreshRecentFiles, onClose]);
+
+ // Cleanup blob URLs when component unmounts
+ useEffect(() => {
+ return () => {
+ // Clean up all created blob URLs
+ createdBlobUrls.current.forEach(url => {
+ URL.revokeObjectURL(url);
+ });
+ createdBlobUrls.current.clear();
+ };
+ }, []);
+
+ // Reset state when modal closes
+ useEffect(() => {
+ if (!isOpen) {
+ setActiveSource('recent');
+ setSelectedFileIds([]);
+ setSearchTerm('');
+ }
+ }, [isOpen]);
+
+ const contextValue: FileManagerContextValue = {
+ // State
+ activeSource,
+ selectedFileIds,
+ searchTerm,
+ selectedFiles,
+ filteredFiles,
+ fileInputRef,
+
+ // Handlers
+ onSourceChange: handleSourceChange,
+ onLocalFileClick: handleLocalFileClick,
+ onFileSelect: handleFileSelect,
+ onFileRemove: handleFileRemove,
+ onFileDoubleClick: handleFileDoubleClick,
+ onOpenFiles: handleOpenFiles,
+ onSearchChange: handleSearchChange,
+ onFileInputChange: handleFileInputChange,
+
+ // External props
+ recentFiles,
+ isFileSupported,
+ modalHeight,
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+// Custom hook to use the context
+export const useFileManagerContext = (): FileManagerContextValue => {
+ const context = useContext(FileManagerContext);
+
+ if (!context) {
+ throw new Error(
+ 'useFileManagerContext must be used within a FileManagerProvider. ' +
+ 'Make sure you wrap your component with .'
+ );
+ }
+
+ return context;
+};
+
+// Export the context for advanced use cases
+export { FileManagerContext };
\ No newline at end of file
diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx
index 6940ab9e7..788db77bd 100644
--- a/frontend/src/contexts/FilesModalContext.tsx
+++ b/frontend/src/contexts/FilesModalContext.tsx
@@ -1,21 +1,58 @@
-import React, { createContext, useContext } from 'react';
-import { useFilesModal, UseFilesModalReturn } from '../hooks/useFilesModal';
+import React, { createContext, useContext, useState, useCallback } from 'react';
import { useFileHandler } from '../hooks/useFileHandler';
-interface FilesModalContextType extends UseFilesModalReturn {}
+interface FilesModalContextType {
+ isFilesModalOpen: boolean;
+ openFilesModal: () => void;
+ closeFilesModal: () => void;
+ onFileSelect: (file: File) => void;
+ onFilesSelect: (files: File[]) => void;
+ onModalClose: () => void;
+ setOnModalClose: (callback: () => void) => void;
+}
const FilesModalContext = createContext(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
-
- const filesModal = useFilesModal({
- onFileSelect: addToActiveFiles,
- onFilesSelect: addMultipleFiles,
- });
+ const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
+ const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
+
+ const openFilesModal = useCallback(() => {
+ setIsFilesModalOpen(true);
+ }, []);
+
+ const closeFilesModal = useCallback(() => {
+ setIsFilesModalOpen(false);
+ onModalClose?.();
+ }, [onModalClose]);
+
+ const handleFileSelect = useCallback((file: File) => {
+ addToActiveFiles(file);
+ closeFilesModal();
+ }, [addToActiveFiles, closeFilesModal]);
+
+ const handleFilesSelect = useCallback((files: File[]) => {
+ addMultipleFiles(files);
+ closeFilesModal();
+ }, [addMultipleFiles, closeFilesModal]);
+
+ const setModalCloseCallback = useCallback((callback: () => void) => {
+ setOnModalClose(() => callback);
+ }, []);
+
+ const contextValue: FilesModalContextType = {
+ isFilesModalOpen,
+ openFilesModal,
+ closeFilesModal,
+ onFileSelect: handleFileSelect,
+ onFilesSelect: handleFilesSelect,
+ onModalClose,
+ setOnModalClose: setModalCloseCallback,
+ };
return (
-
+
{children}
);
diff --git a/frontend/src/hooks/tools/convert/useConvertOperation.ts b/frontend/src/hooks/tools/convert/useConvertOperation.ts
index 3e12ec9e8..ada920e0b 100644
--- a/frontend/src/hooks/tools/convert/useConvertOperation.ts
+++ b/frontend/src/hooks/tools/convert/useConvertOperation.ts
@@ -358,7 +358,10 @@ export const useConvertOperation = (): ConvertOperationHook => {
setDownloadFilename(convertedFile.name);
setStatus(t("downloadComplete"));
- await processResults(new Blob([convertedFile]), convertedFile.name);
+ // Update local files state for hook consumers
+ setFiles([convertedFile]);
+
+ await addFiles([convertedFile]);
markOperationApplied(fileId, operationId);
} catch (error: any) {
console.error(error);
diff --git a/frontend/src/hooks/useFileManager.ts b/frontend/src/hooks/useFileManager.ts
index d8e776f75..efb6724eb 100644
--- a/frontend/src/hooks/useFileManager.ts
+++ b/frontend/src/hooks/useFileManager.ts
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { fileStorage } from '../services/fileStorage';
import { FileWithUrl } from '../types/file';
+import { generateThumbnailForFile } from '../utils/thumbnailUtils';
export const useFileManager = () => {
const [loading, setLoading] = useState(false);
@@ -63,7 +64,12 @@ export const useFileManager = () => {
const storeFile = useCallback(async (file: File) => {
try {
- const storedFile = await fileStorage.storeFile(file);
+ // Generate thumbnail for the file
+ const thumbnail = await generateThumbnailForFile(file);
+
+ // Store file with thumbnail
+ const storedFile = await fileStorage.storeFile(file, thumbnail);
+
// Add the ID to the file object
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
return storedFile;
@@ -111,12 +117,21 @@ export const useFileManager = () => {
};
}, [convertToFile]);
+ const touchFile = useCallback(async (id: string) => {
+ try {
+ await fileStorage.touchFile(id);
+ } catch (error) {
+ console.error('Failed to touch file:', error);
+ }
+ }, []);
+
return {
loading,
convertToFile,
loadRecentFiles,
handleRemoveFile,
storeFile,
+ touchFile,
createFileSelectionHandlers
};
};
\ No newline at end of file
diff --git a/frontend/src/hooks/useFilesModal.ts b/frontend/src/hooks/useFilesModal.ts
deleted file mode 100644
index 49e9f2c5e..000000000
--- a/frontend/src/hooks/useFilesModal.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { useState, useCallback } from 'react';
-
-export interface UseFilesModalReturn {
- isFilesModalOpen: boolean;
- openFilesModal: () => void;
- closeFilesModal: () => void;
- onFileSelect?: (file: File) => void;
- onFilesSelect?: (files: File[]) => void;
- onModalClose?: () => void;
- setOnModalClose: (callback: () => void) => void;
-}
-
-interface UseFilesModalProps {
- onFileSelect?: (file: File) => void;
- onFilesSelect?: (files: File[]) => void;
-}
-
-export const useFilesModal = ({
- onFileSelect,
- onFilesSelect
-}: UseFilesModalProps = {}): UseFilesModalReturn => {
- const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
- const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
-
- const openFilesModal = useCallback(() => {
- setIsFilesModalOpen(true);
- }, []);
-
- const closeFilesModal = useCallback(() => {
- setIsFilesModalOpen(false);
- onModalClose?.();
- }, [onModalClose]);
-
- const handleFileSelect = useCallback((file: File) => {
- onFileSelect?.(file);
- closeFilesModal();
- }, [onFileSelect, closeFilesModal]);
-
- const handleFilesSelect = useCallback((files: File[]) => {
- onFilesSelect?.(files);
- closeFilesModal();
- }, [onFilesSelect, closeFilesModal]);
-
- const setModalCloseCallback = useCallback((callback: () => void) => {
- setOnModalClose(() => callback);
- }, []);
-
- return {
- isFilesModalOpen,
- openFilesModal,
- closeFilesModal,
- onFileSelect: handleFileSelect,
- onFilesSelect: handleFilesSelect,
- onModalClose,
- setOnModalClose: setModalCloseCallback,
- };
-};
\ No newline at end of file
diff --git a/frontend/src/hooks/useIndexedDBThumbnail.ts b/frontend/src/hooks/useIndexedDBThumbnail.ts
index b8404e5fe..b8b2c669c 100644
--- a/frontend/src/hooks/useIndexedDBThumbnail.ts
+++ b/frontend/src/hooks/useIndexedDBThumbnail.ts
@@ -1,6 +1,22 @@
import { useState, useEffect } from "react";
-import { getDocument } from "pdfjs-dist";
import { FileWithUrl } from "../types/file";
+import { fileStorage } from "../services/fileStorage";
+import { generateThumbnailForFile } from "../utils/thumbnailUtils";
+
+/**
+ * Calculate optimal scale for thumbnail generation
+ * Ensures high quality while preventing oversized renders
+ */
+function calculateThumbnailScale(pageViewport: { width: number; height: number }): number {
+ const maxWidth = 400; // Max thumbnail width
+ const maxHeight = 600; // Max thumbnail height
+
+ const scaleX = maxWidth / pageViewport.width;
+ const scaleY = maxHeight / pageViewport.height;
+
+ // Don't upscale, only downscale if needed
+ return Math.min(scaleX, scaleY, 1.0);
+}
/**
* Hook for IndexedDB-aware thumbnail loading
@@ -28,38 +44,55 @@ export function useIndexedDBThumbnail(file: FileWithUrl | undefined | null): {
return;
}
- // Second priority: for IndexedDB files without stored thumbnails, just use placeholder
- if (file.storedInIndexedDB && file.id) {
- // Don't generate thumbnails for files loaded from IndexedDB - just use placeholder
- setThumb(null);
- return;
- }
-
- // Third priority: generate from blob for regular files during upload (small files only)
- if (!file.storedInIndexedDB && file.size < 50 * 1024 * 1024 && !generating) {
+ // Second priority: generate thumbnail for any file type
+ if (file.size < 100 * 1024 * 1024 && !generating) {
setGenerating(true);
try {
- const arrayBuffer = await file.arrayBuffer();
- const pdf = await getDocument({ data: arrayBuffer }).promise;
- const page = await pdf.getPage(1);
- const viewport = page.getViewport({ scale: 0.2 });
- const canvas = document.createElement("canvas");
- canvas.width = viewport.width;
- canvas.height = viewport.height;
- const context = canvas.getContext("2d");
- if (context && !cancelled) {
- await page.render({ canvasContext: context, viewport }).promise;
- if (!cancelled) setThumb(canvas.toDataURL());
+ let fileObject: File;
+
+ // Handle IndexedDB files vs regular File objects
+ if (file.storedInIndexedDB && file.id) {
+ // For IndexedDB files, recreate File object from stored data
+ const storedFile = await fileStorage.getFile(file.id);
+ if (!storedFile) {
+ throw new Error('File not found in IndexedDB');
+ }
+ fileObject = new File([storedFile.data], storedFile.name, {
+ type: storedFile.type,
+ lastModified: storedFile.lastModified
+ });
+ } else if (file.file) {
+ // For FileWithUrl objects that have a File object
+ fileObject = file.file;
+ } else if (file.id) {
+ // Fallback: try to get from IndexedDB even if storedInIndexedDB flag is missing
+ const storedFile = await fileStorage.getFile(file.id);
+ if (!storedFile) {
+ throw new Error('File not found in IndexedDB and no File object available');
+ }
+ fileObject = new File([storedFile.data], storedFile.name, {
+ type: storedFile.type,
+ lastModified: storedFile.lastModified
+ });
+ } else {
+ throw new Error('File object not available and no ID for IndexedDB lookup');
+ }
+
+ // Use the universal thumbnail generator
+ const thumbnail = await generateThumbnailForFile(fileObject);
+ if (!cancelled && thumbnail) {
+ setThumb(thumbnail);
+ } else if (!cancelled) {
+ setThumb(null);
}
- pdf.destroy(); // Clean up memory
} catch (error) {
- console.warn('Failed to generate thumbnail for regular file', file.name, error);
+ console.warn('Failed to generate thumbnail for file', file.name, error);
if (!cancelled) setThumb(null);
} finally {
if (!cancelled) setGenerating(false);
}
} else {
- // Large files or files without proper conditions - show placeholder
+ // Large files - generate placeholder
setThumb(null);
}
}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 94a81ee6d..b7a352f0f 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -20,7 +20,7 @@ import Viewer from "../components/viewer/Viewer";
import ToolRenderer from "../components/tools/ToolRenderer";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import LandingPage from "../components/shared/LandingPage";
-import FileUploadModal from "../components/shared/FileUploadModal";
+import FileManager from "../components/FileManager";
function HomePageContent() {
@@ -279,7 +279,7 @@ function HomePageContent() {
{/* Global Modals */}
-
+
);
}
diff --git a/frontend/src/services/fileStorage.ts b/frontend/src/services/fileStorage.ts
index 9ba2e7def..5fd5739e8 100644
--- a/frontend/src/services/fileStorage.ts
+++ b/frontend/src/services/fileStorage.ts
@@ -225,6 +225,32 @@ class FileStorageService {
});
}
+ /**
+ * Update the lastModified timestamp of a file (for most recently used sorting)
+ */
+ async touchFile(id: string): Promise {
+ if (!this.db) await this.init();
+ return new Promise((resolve, reject) => {
+ const transaction = this.db!.transaction([this.storeName], 'readwrite');
+ const store = transaction.objectStore(this.storeName);
+
+ const getRequest = store.get(id);
+ getRequest.onsuccess = () => {
+ const file = getRequest.result;
+ if (file) {
+ // Update lastModified to current timestamp
+ file.lastModified = Date.now();
+ const updateRequest = store.put(file);
+ updateRequest.onsuccess = () => resolve(true);
+ updateRequest.onerror = () => reject(updateRequest.error);
+ } else {
+ resolve(false); // File not found
+ }
+ };
+ getRequest.onerror = () => reject(getRequest.error);
+ });
+ }
+
/**
* Clear all stored files
*/
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index 1cf3581c4..9ec48bca7 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -74,6 +74,9 @@
--bg-muted: #f3f4f6;
--bg-background: #f9fafb;
--bg-toolbar: #ffffff;
+ --bg-file-manager: #F5F6F8;
+ --bg-file-list: #ffffff;
+ --btn-open-file: #0A8BFF;
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
@@ -175,6 +178,9 @@
--bg-muted: #1F2329;
--bg-background: #2A2F36;
--bg-toolbar: #272A2E;
+ --bg-file-manager: #1F2329;
+ --bg-file-list: #2A2F36;
+ --btn-open-file: #0A8BFF;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
diff --git a/frontend/src/tests/convert/ConvertIntegration.test.tsx b/frontend/src/tests/convert/ConvertIntegration.test.tsx
index c9a636035..5ac978810 100644
--- a/frontend/src/tests/convert/ConvertIntegration.test.tsx
+++ b/frontend/src/tests/convert/ConvertIntegration.test.tsx
@@ -23,13 +23,31 @@ import axios from 'axios';
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
-// Mock utility modules
-vi.mock('../../utils/thumbnailUtils', () => ({
- generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
+// Mock only essential services that are actually called by the tests
+vi.mock('../../services/fileStorage', () => ({
+ fileStorage: {
+ init: vi.fn().mockResolvedValue(undefined),
+ storeFile: vi.fn().mockImplementation((file, thumbnail) => {
+ return Promise.resolve({
+ id: `mock-id-${file.name}`,
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ lastModified: file.lastModified,
+ thumbnail: thumbnail
+ });
+ }),
+ getAllFileMetadata: vi.fn().mockResolvedValue([]),
+ cleanup: vi.fn().mockResolvedValue(undefined)
+ }
}));
-vi.mock('../../utils/api', () => ({
- makeApiUrl: vi.fn((path: string) => `/api/v1${path}`)
+vi.mock('../../services/thumbnailGenerationService', () => ({
+ thumbnailGenerationService: {
+ generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
+ cleanup: vi.fn(),
+ destroy: vi.fn()
+ }
}));
// Create realistic test files
@@ -194,7 +212,14 @@ describe('Convert Tool Integration Tests', () => {
test('should correctly map image conversion parameters to API call', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/jpeg' });
- mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
+ mockedAxios.post.mockResolvedValueOnce({
+ data: mockBlob,
+ status: 200,
+ headers: {
+ 'content-type': 'image/jpeg',
+ 'content-disposition': 'attachment; filename="test_converted.jpg"'
+ }
+ });
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -472,7 +497,14 @@ describe('Convert Tool Integration Tests', () => {
test('should record operation in FileContext', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
- mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
+ mockedAxios.post.mockResolvedValueOnce({
+ data: mockBlob,
+ status: 200,
+ headers: {
+ 'content-type': 'image/png',
+ 'content-disposition': 'attachment; filename="test_converted.png"'
+ }
+ });
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
@@ -506,7 +538,14 @@ describe('Convert Tool Integration Tests', () => {
test('should clean up blob URLs on reset', async () => {
const mockBlob = new Blob(['fake-data'], { type: 'image/png' });
- mockedAxios.post.mockResolvedValueOnce({ data: mockBlob });
+ mockedAxios.post.mockResolvedValueOnce({
+ data: mockBlob,
+ status: 200,
+ headers: {
+ 'content-type': 'image/png',
+ 'content-disposition': 'attachment; filename="test_converted.png"'
+ }
+ });
const { result } = renderHook(() => useConvertOperation(), {
wrapper: TestWrapper
diff --git a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
index 3fac5b4ba..64aafc488 100644
--- a/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
+++ b/frontend/src/tests/convert/ConvertSmartDetectionIntegration.test.tsx
@@ -18,9 +18,31 @@ import { detectFileExtension } from '../../utils/fileUtils';
vi.mock('axios');
const mockedAxios = vi.mocked(axios);
-// Mock utility modules
-vi.mock('../../utils/thumbnailUtils', () => ({
- generateThumbnailForFile: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail')
+// Mock only essential services that are actually called by the tests
+vi.mock('../../services/fileStorage', () => ({
+ fileStorage: {
+ init: vi.fn().mockResolvedValue(undefined),
+ storeFile: vi.fn().mockImplementation((file, thumbnail) => {
+ return Promise.resolve({
+ id: `mock-id-${file.name}`,
+ name: file.name,
+ size: file.size,
+ type: file.type,
+ lastModified: file.lastModified,
+ thumbnail: thumbnail
+ });
+ }),
+ getAllFileMetadata: vi.fn().mockResolvedValue([]),
+ cleanup: vi.fn().mockResolvedValue(undefined)
+ }
+}));
+
+vi.mock('../../services/thumbnailGenerationService', () => ({
+ thumbnailGenerationService: {
+ generateThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,fake-thumbnail'),
+ cleanup: vi.fn(),
+ destroy: vi.fn()
+ }
}));
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
diff --git a/frontend/src/utils/fileUtils.ts b/frontend/src/utils/fileUtils.ts
index b42d2f646..682cd9f3c 100644
--- a/frontend/src/utils/fileUtils.ts
+++ b/frontend/src/utils/fileUtils.ts
@@ -1,8 +1,8 @@
import { FileWithUrl } from "../types/file";
import { StoredFile, fileStorage } from "../services/fileStorage";
-export function getFileId(file: File): string {
- return (file as File & { id?: string }).id || file.name;
+export function getFileId(file: File): string | null {
+ return (file as File & { id?: string }).id || null;
}
/**
diff --git a/frontend/src/utils/thumbnailUtils.ts b/frontend/src/utils/thumbnailUtils.ts
index 35444035a..f4f224044 100644
--- a/frontend/src/utils/thumbnailUtils.ts
+++ b/frontend/src/utils/thumbnailUtils.ts
@@ -15,19 +15,172 @@ export function calculateScaleFromFileSize(fileSize: number): number {
}
/**
- * Generate thumbnail for a PDF file during upload
+ * Generate modern placeholder thumbnail with file extension
+ */
+function generatePlaceholderThumbnail(file: File): string {
+ const canvas = document.createElement('canvas');
+ canvas.width = 120;
+ canvas.height = 150;
+ const ctx = canvas.getContext('2d')!;
+
+ // Get file extension for color theming
+ const extension = file.name.split('.').pop()?.toUpperCase() || 'FILE';
+ const colorScheme = getFileTypeColorScheme(extension);
+
+ // Create gradient background
+ const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
+ gradient.addColorStop(0, colorScheme.bgTop);
+ gradient.addColorStop(1, colorScheme.bgBottom);
+
+ // Rounded rectangle background
+ drawRoundedRect(ctx, 8, 8, canvas.width - 16, canvas.height - 16, 8);
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ // Subtle shadow/border
+ ctx.strokeStyle = colorScheme.border;
+ ctx.lineWidth = 1.5;
+ ctx.stroke();
+
+ // Modern document icon
+ drawModernDocumentIcon(ctx, canvas.width / 2, 45, colorScheme.icon);
+
+ // Extension badge
+ drawExtensionBadge(ctx, canvas.width / 2, canvas.height / 2 + 15, extension, colorScheme);
+
+ // File size with subtle styling
+ const sizeText = formatFileSize(file.size);
+ ctx.font = '11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+ ctx.fillStyle = colorScheme.textSecondary;
+ ctx.textAlign = 'center';
+ ctx.fillText(sizeText, canvas.width / 2, canvas.height - 15);
+
+ return canvas.toDataURL();
+}
+
+/**
+ * Get color scheme based on file extension
+ */
+function getFileTypeColorScheme(extension: string) {
+ const schemes: Record = {
+ // Documents
+ 'PDF': { bgTop: '#FF6B6B20', bgBottom: '#FF6B6B10', border: '#FF6B6B40', icon: '#FF6B6B', badge: '#FF6B6B', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'DOC': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'DOCX': { bgTop: '#4ECDC420', bgBottom: '#4ECDC410', border: '#4ECDC440', icon: '#4ECDC4', badge: '#4ECDC4', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'TXT': { bgTop: '#95A5A620', bgBottom: '#95A5A610', border: '#95A5A640', icon: '#95A5A6', badge: '#95A5A6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+
+ // Spreadsheets
+ 'XLS': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'XLSX': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'CSV': { bgTop: '#2ECC7120', bgBottom: '#2ECC7110', border: '#2ECC7140', icon: '#2ECC71', badge: '#2ECC71', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+
+ // Presentations
+ 'PPT': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'PPTX': { bgTop: '#E67E2220', bgBottom: '#E67E2210', border: '#E67E2240', icon: '#E67E22', badge: '#E67E22', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+
+ // Archives
+ 'ZIP': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ 'RAR': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+ '7Z': { bgTop: '#9B59B620', bgBottom: '#9B59B610', border: '#9B59B640', icon: '#9B59B6', badge: '#9B59B6', textPrimary: '#FFFFFF', textSecondary: '#666666' },
+
+ // Default
+ 'DEFAULT': { bgTop: '#74B9FF20', bgBottom: '#74B9FF10', border: '#74B9FF40', icon: '#74B9FF', badge: '#74B9FF', textPrimary: '#FFFFFF', textSecondary: '#666666' }
+ };
+
+ return schemes[extension] || schemes['DEFAULT'];
+}
+
+/**
+ * Draw rounded rectangle
+ */
+function drawRoundedRect(ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number) {
+ ctx.beginPath();
+ ctx.moveTo(x + radius, y);
+ ctx.lineTo(x + width - radius, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+ ctx.lineTo(x + width, y + height - radius);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+ ctx.lineTo(x + radius, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+ ctx.lineTo(x, y + radius);
+ ctx.quadraticCurveTo(x, y, x + radius, y);
+ ctx.closePath();
+}
+
+/**
+ * Draw modern document icon
+ */
+function drawModernDocumentIcon(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, color: string) {
+ const size = 24;
+ ctx.fillStyle = color;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 2;
+
+ // Document body
+ drawRoundedRect(ctx, centerX - size/2, centerY - size/2, size, size * 1.2, 3);
+ ctx.fill();
+
+ // Folded corner
+ ctx.beginPath();
+ ctx.moveTo(centerX + size/2 - 6, centerY - size/2);
+ ctx.lineTo(centerX + size/2, centerY - size/2 + 6);
+ ctx.lineTo(centerX + size/2 - 6, centerY - size/2 + 6);
+ ctx.closePath();
+ ctx.fillStyle = '#FFFFFF40';
+ ctx.fill();
+}
+
+/**
+ * Draw extension badge
+ */
+function drawExtensionBadge(ctx: CanvasRenderingContext2D, centerX: number, centerY: number, extension: string, colorScheme: any) {
+ const badgeWidth = Math.max(extension.length * 8 + 16, 40);
+ const badgeHeight = 22;
+
+ // Badge background
+ drawRoundedRect(ctx, centerX - badgeWidth/2, centerY - badgeHeight/2, badgeWidth, badgeHeight, 11);
+ ctx.fillStyle = colorScheme.badge;
+ ctx.fill();
+
+ // Badge text
+ ctx.font = 'bold 11px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif';
+ ctx.fillStyle = colorScheme.textPrimary;
+ ctx.textAlign = 'center';
+ ctx.fillText(extension, centerX, centerY + 4);
+}
+
+/**
+ * Format file size for display
+ */
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
+}
+
+
+/**
+ * Generate thumbnail for any file type
* Returns base64 data URL or undefined if generation fails
*/
export async function generateThumbnailForFile(file: File): Promise {
- // Skip thumbnail generation for large files to avoid memory issues
- if (file.size >= 50 * 1024 * 1024) { // 50MB limit
+ // Skip thumbnail generation for very large files to avoid memory issues
+ if (file.size >= 100 * 1024 * 1024) { // 100MB limit
console.log('Skipping thumbnail generation for large file:', file.name);
- return undefined;
+ return generatePlaceholderThumbnail(file);
}
+ // Handle image files - use original file directly
+ if (file.type.startsWith('image/')) {
+ return URL.createObjectURL(file);
+ }
+
+ // Handle PDF files
if (!file.type.startsWith('application/pdf')) {
- console.warn('File is not a PDF, skipping thumbnail generation:', file.name);
- return undefined;
+ console.log('File is not a PDF or image, generating placeholder:', file.name);
+ return generatePlaceholderThumbnail(file);
}
try {