Merge branch 'V2' into V2-sanitize

This commit is contained in:
ConnorYoh 2025-08-08 15:32:39 +01:00 committed by GitHub
commit 5020805244
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
58 changed files with 3290 additions and 572 deletions

View File

@ -352,10 +352,10 @@ jobs:
docker-compose up -d
# Clean up unused Docker resources to save space
docker system prune -af --volumes
docker system prune -af --volumes || true
# Clean up old backend/frontend images (older than 2 weeks)
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
# Set port for output
@ -490,7 +490,7 @@ jobs:
fi
# Clean up old unused images (older than 2 weeks) but keep recent ones for reuse
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
# Note: We don't remove the commit-based images since they can be reused across PRs
# Only remove PR-specific containers and directories

View File

@ -177,8 +177,8 @@ jobs:
docker-compose down || true
docker-compose pull
docker-compose up -d
docker system prune -af --volumes
docker image prune -af --filter "until=336h" --filter "label!=keep=true"
docker system prune -af --volumes || true
docker image prune -af --filter "until=336h" --filter "label!=keep=true" || true
ENDSSH
- name: Cleanup temporary files

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.4 KiB

View File

@ -0,0 +1,4 @@
<svg width="146" height="157" viewBox="0 0 146 157" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#E6E6E6" fill-opacity="0.9"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#E6E6E6" fill-opacity="0.8"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

View File

@ -0,0 +1,4 @@
<svg width="146" height="146" viewBox="0 0 146 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.77397 72.5462L93.6741 23.0462L94.7739 70.0462L3.77395 119.046L3.77397 72.5462Z" fill="#ACACAC" fill-opacity="0.3"/>
<path d="M50.774 73.5735L96.387 50.2673L142 26.961L142 71.687L50.7739 122.046L50.774 73.5735Z" fill="#FC9999" fill-opacity="0.5"/>
</svg>

After

Width:  |  Height:  |  Size: 366 B

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.2 KiB

View File

@ -1738,7 +1738,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",

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" fill="none">
<path d="M7.26375 95.8344L123.374 4.32822e-05L123.375 89.4987L7.26375 185.333L7.26375 95.8344Z" fill="white"/>
<path d="M68.4794 102.395L184.728 6.44717L184.728 96.052L68.4794 192L68.4794 102.395Z" fill="white" fill-opacity="0.6"/>
</svg>

After

Width:  |  Height:  |  Size: 339 B

View File

@ -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<FileManagerProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFilesSelect } = useFilesModalContext();
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
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 (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
size={isMobile ? "100%" : "auto"}
centered
radius={30}
className="overflow-hidden p-0"
withCloseButton={false}
styles={{
content: {
position: 'relative',
margin: isMobile ? '1rem' : '2rem'
},
body: { padding: 0 },
header: { display: 'none' }
}}
>
<div style={{
position: 'relative',
height: modalHeight,
width: modalWidth,
maxWidth: modalMaxWidth,
maxHeight: modalMaxHeight,
minWidth: modalMinWidth,
margin: '0 auto',
overflow: 'hidden'
}}>
<Dropzone
onDrop={handleNewFileUpload}
onDragEnter={() => 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' }
}}
>
<FileManagerProvider
recentFiles={recentFiles}
onFilesSelected={handleFilesSelected}
onClose={closeFilesModal}
isFileSupported={isFileSupported}
isOpen={isFilesModalOpen}
onFileRemove={handleRemoveFileByIndex}
modalHeight={modalHeight}
storeFile={storeFile}
refreshRecentFiles={refreshRecentFiles}
>
{isMobile ? <MobileLayout /> : <DesktopLayout />}
</FileManagerProvider>
</Dropzone>
<DragOverlay isVisible={isDragging} />
</div>
</Modal>
);
};
export default FileManager;

View File

@ -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 (
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
<Card withBorder p="sm">
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
</Text>
<Progress
value={hardLimitPercent}
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
size="sm"
mt={4}
/>
<Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed">
{storageStats.fileCount} files {t("storage.approximateSize", "Approximate size")}
</Text>
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
{Math.round(hardLimitPercent)}% used
</Text>
</Group>
{isNearLimit && (
<Text size="xs" c="red" mt={4}>
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
</Text>
)}
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
{t("fileManager.reloadFiles", "Reload Files")}
</Button>
</Group>
</Group>
</Card>
</Stack>
);
};
export default StorageStatsCard;

View File

@ -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<CompactFileDetailsProps> = ({
currentFile,
thumbnail,
selectedFiles,
currentFileIndex,
numberOfFiles,
isAnimating,
onPrevious,
onNext,
onOpenFiles
}) => {
const { t } = useTranslation();
const hasSelection = selectedFiles.length > 0;
const hasMultipleFiles = numberOfFiles > 1;
return (
<Stack gap="xs" style={{ height: '100%' }}>
{/* Compact mobile layout */}
<Box style={{ display: 'flex', gap: '0.75rem', alignItems: 'center' }}>
{/* Small preview */}
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{currentFile && thumbnail ? (
<img
src={thumbnail}
alt={currentFile.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: '0.25rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)'
}}
/>
) : currentFile ? (
<Center style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 4
}}>
<PictureAsPdfIcon style={{ fontSize: 20, color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* File info */}
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{currentFile ? currentFile.name : 'No file selected'}
</Text>
<Text size="xs" c="dimmed">
{currentFile ? getFileSize(currentFile) : ''}
{selectedFiles.length > 1 && `${selectedFiles.length} files`}
</Text>
{hasMultipleFiles && (
<Text size="xs" c="blue">
{currentFileIndex + 1} of {selectedFiles.length}
</Text>
)}
</Box>
{/* Navigation arrows for multiple files */}
{hasMultipleFiles && (
<Box style={{ display: 'flex', gap: '0.25rem' }}>
<ActionIcon
variant="subtle"
size="sm"
onClick={onPrevious}
disabled={isAnimating}
>
<ChevronLeftIcon style={{ fontSize: 16 }} />
</ActionIcon>
<ActionIcon
variant="subtle"
size="sm"
onClick={onNext}
disabled={isAnimating}
>
<ChevronRightIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Box>
)}
</Box>
{/* Action Button */}
<Button
size="sm"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
style={{
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
</Button>
</Stack>
);
};
export default CompactFileDetails;

View File

@ -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 (
<Grid gutter="xs" h="100%" grow={false} style={{ flexWrap: 'nowrap', minWidth: 0 }}>
{/* Column 1: File Sources */}
<Grid.Col span="content" p="lg" style={{
minWidth: '13.625rem',
width: '13.625rem',
flexShrink: 0,
height: '100%',
}}>
<FileSourceButtons />
</Grid.Col>
{/* Column 2: File List */}
<Grid.Col span="auto" style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
minHeight: 0,
minWidth: 0,
flex: '1 1 0px'
}}>
<div style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
border: '1px solid var(--mantine-color-gray-2)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
overflow: 'hidden'
}}>
{activeSource === 'recent' && (
<div style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-3)'
}}>
<SearchInput />
</div>
)}
<div style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={`calc(${modalHeight} )`}
scrollAreaStyle={{
height: activeSource === 'recent' && recentFiles.length > 0 ? modalHeight : '100%',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0
}}
/>
</div>
</div>
</Grid.Col>
{/* Column 3: File Details */}
<Grid.Col p="xl" span="content" style={{
minWidth: '25rem',
width: '25rem',
flexShrink: 0,
height: '100%',
maxWidth: '18rem'
}}>
<div style={{ height: '100%', overflow: 'hidden' }}>
<FileDetails />
</div>
</Grid.Col>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Grid>
);
};
export default DesktopLayout;

View File

@ -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<DragOverlayProps> = ({ isVisible }) => {
const { t } = useTranslation();
const theme = useMantineTheme();
if (!isVisible) return null;
return (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: alpha(theme.colors.blue[6], 0.1),
border: `0.125rem dashed ${theme.colors.blue[6]}`,
borderRadius: '1.875rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
pointerEvents: 'none'
}}
>
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: '4rem', color: theme.colors.blue[6] }} />
<Text size="xl" fw={500} c="blue.6">
{t('fileManager.dropFilesHere', 'Drop files here to upload')}
</Text>
</Stack>
</div>
);
};
export default DragOverlay;

View File

@ -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<FileDetailsProps> = ({
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 (
<CompactFileDetails
currentFile={currentFile}
thumbnail={getCurrentThumbnail()}
selectedFiles={selectedFiles}
currentFileIndex={currentFileIndex}
numberOfFiles={selectedFiles.length}
isAnimating={isAnimating}
onPrevious={handlePrevious}
onNext={handleNext}
onOpenFiles={onOpenFiles}
/>
);
}
return (
<Stack gap="lg" h={`calc(${modalHeight} - 2rem)`}>
{/* Section 1: Thumbnail Preview */}
<FilePreview
currentFile={currentFile}
thumbnail={getCurrentThumbnail()}
numberOfFiles={selectedFiles.length}
isAnimating={isAnimating}
modalHeight={modalHeight}
onPrevious={handlePrevious}
onNext={handleNext}
/>
{/* Section 2: File Details */}
<FileInfoCard
currentFile={currentFile}
modalHeight={modalHeight}
/>
<Button
size="md"
mb="xl"
onClick={onOpenFiles}
disabled={!hasSelection}
fullWidth
style={{
flexShrink: 0,
backgroundColor: hasSelection ? 'var(--btn-open-file)' : 'var(--mantine-color-gray-4)',
color: 'white'
}}
>
{selectedFiles.length > 1
? t('fileManager.openFiles', `Open ${selectedFiles.length} Files`)
: t('fileManager.openFile', 'Open File')
}
</Button>
</Stack>
);
};
export default FileDetails;

View File

@ -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<FileInfoCardProps> = ({
currentFile,
modalHeight
}) => {
const { t } = useTranslation();
return (
<Card withBorder p={0} h={`calc(${modalHeight} * 0.32 - 1rem)`} style={{ flex: 1, overflow: 'hidden' }}>
<Box bg="blue.6" p="sm" style={{ borderTopLeftRadius: 'var(--mantine-radius-md)', borderTopRightRadius: 'var(--mantine-radius-md)' }}>
<Text size="sm" fw={500} ta="center" c="white">
{t('fileManager.details', 'File Details')}
</Text>
</Box>
<ScrollArea style={{ flex: 1 }} p="md">
<Stack gap="sm">
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileName', 'Name')}</Text>
<Text size="sm" fw={500} style={{ maxWidth: '60%', textAlign: 'right' }} truncate>
{currentFile ? currentFile.name : ''}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileFormat', 'Format')}</Text>
{currentFile ? (
<Badge size="sm" variant="light">
{detectFileExtension(currentFile.name).toUpperCase()}
</Badge>
) : (
<Text size="sm" fw={500}></Text>
)}
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileSize', 'Size')}</Text>
<Text size="sm" fw={500}>
{currentFile ? getFileSize(currentFile) : ''}
</Text>
</Group>
<Divider />
<Group justify="space-between" py="xs">
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
<Text size="sm" fw={500}>
{currentFile ? '1.0' : ''}
</Text>
</Group>
</Stack>
</ScrollArea>
</Card>
);
};
export default FileInfoCard;

View File

@ -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<FileListAreaProps> = ({
scrollAreaHeight,
scrollAreaStyle = {},
}) => {
const {
activeSource,
recentFiles,
filteredFiles,
selectedFileIds,
onFileSelect,
onFileRemove,
onFileDoubleClick,
isFileSupported,
} = useFileManagerContext();
const { t } = useTranslation();
if (activeSource === 'recent') {
return (
<ScrollArea
h={scrollAreaHeight}
style={{
...scrollAreaStyle
}}
type="always"
scrollbarSize={8}
>
<Stack gap={0}>
{recentFiles.length === 0 ? (
<Center style={{ height: '12.5rem' }}>
<Stack align="center" gap="sm">
<HistoryIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.noRecentFiles', 'No recent files')}</Text>
<Text size="xs" c="dimmed" ta="center" style={{ opacity: 0.7 }}>
{t('fileManager.dropFilesHint', 'Drop files anywhere to upload')}
</Text>
</Stack>
</Center>
) : (
filteredFiles.map((file, index) => (
<FileListItem
key={file.id || file.name}
file={file}
isSelected={selectedFileIds.includes(file.id || file.name)}
isSupported={isFileSupported(file.name)}
onSelect={() => onFileSelect(file)}
onRemove={() => onFileRemove(index)}
onDoubleClick={() => onFileDoubleClick(file)}
/>
))
)}
</Stack>
</ScrollArea>
);
}
// Google Drive placeholder
return (
<Center style={{ height: '12.5rem' }}>
<Stack align="center" gap="sm">
<CloudIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-5)' }} />
<Text c="dimmed" ta="center">{t('fileManager.googleDriveNotAvailable', 'Google Drive integration coming soon')}</Text>
</Stack>
</Center>
);
};
export default FileListArea;

View File

@ -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<FileListItemProps> = ({
file,
isSelected,
isSupported,
onSelect,
onRemove,
onDoubleClick
}) => {
const [isHovered, setIsHovered] = useState(false);
return (
<>
<Box
p="sm"
style={{
cursor: 'pointer',
backgroundColor: isSelected ? 'var(--mantine-color-gray-0)' : (isHovered ? 'var(--mantine-color-gray-0)' : 'var(--bg-file-list)'),
opacity: isSupported ? 1 : 0.5,
transition: 'background-color 0.15s ease'
}}
onClick={onSelect}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Group gap="sm">
<Box>
<Checkbox
checked={isSelected}
onChange={() => {}} // Handled by parent onClick
size="sm"
pl="sm"
pr="xs"
styles={{
input: {
cursor: 'pointer'
}
}}
/>
</Box>
<Box style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Delete button - fades in/out on hover */}
<ActionIcon
variant="subtle"
c="dimmed"
size="md"
onClick={(e) => { 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'
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Group>
</Box>
{ <Divider color="var(--mantine-color-gray-3)" />}
</>
);
};
export default FileListItem;

View File

@ -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<FilePreviewProps> = ({
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 (
<Box p="xs" style={{ textAlign: 'center', flexShrink: 0 }}>
<Box style={{ position: 'relative', width: "100%", height: `calc(${modalHeight} * 0.5 - 2rem)`, margin: '0 auto', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Left Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={onPrevious}
color="blue"
disabled={isAnimating}
style={{
...navigationArrowStyle,
left: '0'
}}
>
<ChevronLeftIcon />
</ActionIcon>
)}
{/* Document Stack Container */}
<Box style={{ position: 'relative', width: '100%', height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{/* Background documents (stack effect) */}
{/* Show 2 shadow pages for 3+ files */}
{numberOfFiles >= 3 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-3)',
boxShadow: stackDocumentShadows.back,
transform: 'translate(0.75rem, 0.75rem) rotate(2deg)',
zIndex: 1
}}
/>
)}
{/* Show 1 shadow page for 2+ files */}
{numberOfFiles >= 2 && (
<Box
style={{
...stackDocumentBaseStyle,
backgroundColor: 'var(--mantine-color-gray-2)',
boxShadow: stackDocumentShadows.middle,
transform: 'translate(0.375rem, 0.375rem) rotate(1deg)',
zIndex: 2
}}
/>
)}
{/* Main document */}
{currentFile && thumbnail ? (
<Image
src={thumbnail}
alt={currentFile.name}
fit="contain"
style={{
maxWidth: '100%',
maxHeight: '100%',
width: 'auto',
height: 'auto',
boxShadow: mainDocumentShadow,
position: 'relative',
zIndex: 3,
...animationStyle
}}
/>
) : currentFile ? (
<Center style={{
width: '80%',
height: '80%',
backgroundColor: 'var(--mantine-color-gray-1)',
boxShadow: mainDocumentShadow,
position: 'relative',
zIndex: 3,
...animationStyle
}}>
<PictureAsPdfIcon style={{ fontSize: '3rem', color: 'var(--mantine-color-gray-6)' }} />
</Center>
) : null}
</Box>
{/* Right Navigation Arrow */}
{hasMultipleFiles && (
<ActionIcon
variant="light"
size="sm"
onClick={onNext}
color="blue"
disabled={isAnimating}
style={{
...navigationArrowStyle,
right: '0'
}}
>
<ChevronRightIcon />
</ActionIcon>
)}
</Box>
</Box>
);
};
export default FilePreview;

View File

@ -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<FileSourceButtonsProps> = ({
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 = (
<>
<Button
leftSection={<HistoryIcon />}
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')}
</Button>
<Button
variant="subtle"
color='var(--mantine-color-gray-6)'
leftSection={<FolderIcon />}
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')}
</Button>
<Button
variant={buttonProps.variant('drive')}
leftSection={<CloudIcon />}
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')}
</Button>
</>
);
if (horizontal) {
return (
<Group gap="xs" justify="center" style={{ width: '100%' }}>
{buttons}
</Group>
);
}
return (
<Stack gap="xs" style={{ height: '100%' }}>
<Text size="sm" pt="sm" fw={500} c="dimmed" mb="xs" style={{ paddingLeft: '1rem' }}>
{t('fileManager.myFiles', 'My Files')}
</Text>
{buttons}
</Stack>
);
};
export default FileSourceButtons;

View File

@ -0,0 +1,20 @@
import React from 'react';
import { useFileManagerContext } from '../../contexts/FileManagerContext';
const HiddenFileInput: React.FC = () => {
const { fileInputRef, onFileInputChange } = useFileManagerContext();
return (
<input
ref={fileInputRef}
type="file"
multiple={true}
accept="*/*"
onChange={onFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
);
};
export default HiddenFileInput;

View File

@ -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 (
<Box h="100%" p="sm" style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
{/* Section 1: File Sources - Fixed at top */}
<Box style={{ flexShrink: 0 }}>
<FileSourceButtons horizontal={true} />
</Box>
<Box style={{ flexShrink: 0 }}>
<FileDetails compact={true} />
</Box>
{/* Section 3 & 4: Search Bar + File List - Unified background extending to modal edge */}
<Box style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
backgroundColor: 'var(--bg-file-list)',
borderRadius: '0.5rem',
border: '1px solid var(--mantine-color-gray-2)',
overflow: 'hidden',
minHeight: 0
}}>
{activeSource === 'recent' && (
<Box style={{
flexShrink: 0,
borderBottom: '1px solid var(--mantine-color-gray-2)'
}}>
<SearchInput />
</Box>
)}
<Box style={{ flex: 1, minHeight: 0 }}>
<FileListArea
scrollAreaHeight={calculateFileListHeight()}
scrollAreaStyle={{
height: calculateFileListHeight(),
maxHeight: '60vh',
minHeight: '9.375rem',
backgroundColor: 'transparent',
border: 'none',
borderRadius: 0
}}
/>
</Box>
</Box>
{/* Hidden file input for local file selection */}
<HiddenFileInput />
</Box>
);
};
export default MobileLayout;

View File

@ -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<SearchInputProps> = ({ style }) => {
const { t } = useTranslation();
const { searchTerm, onSearchChange } = useFileManagerContext();
return (
<TextInput
placeholder={t('fileManager.searchFiles', 'Search files...')}
leftSection={<SearchIcon />}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
style={{ padding: '0.5rem', ...style }}
styles={{
input: {
border: 'none',
backgroundColor: 'transparent'
}
}}
/>
);
};
export default SearchInput;

View File

@ -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";

View File

@ -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 {

View File

@ -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<FileUploadModalProps> = ({ selectedTool }) => {
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
return (
<Modal
opened={isFilesModalOpen}
onClose={closeFilesModal}
title="Upload Files"
size="xl"
centered
>
<FileUploadSelector
title="Upload Files"
subtitle="Choose files from storage or upload new files"
onFileSelect={onFileSelect}
onFilesSelect={onFilesSelect}
accept={["*/*"]}
supportedExtensions={selectedTool?.supportedFormats || ["pdf"]}
data-testid="file-upload-modal"
/>
</Modal>
);
};
export default FileUploadModal;

View File

@ -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<HTMLInputElement>(null);
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
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<HTMLInputElement>) => {
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 (
<>
<Stack align="center" gap="sm">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
{displayTitle}
</Text>
<Text size="md" c="dimmed">
{displaySubtitle}
</Text>
</Stack>
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
{showDropzone ? (
<Dropzone
onDrop={handleNewFileUpload}
accept={accept}
multiple={true}
disabled={disabled || loading}
style={{ width: '100%', height: "5rem" }}
activateOnClick={true}
data-testid="file-dropzone"
>
<Center>
<Stack align="center" gap="sm">
<Text size="md" fw={500}>
{t("fileUpload.dropFilesHere", "Drop files here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{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")
}
</Text>
</Stack>
</Center>
</Dropzone>
) : (
<Stack align="center" gap="sm">
<Button
variant="outline"
size="lg"
disabled={disabled}
loading={loading}
onClick={openFileDialog}
>
{t("fileUpload.uploadFiles", "Upload Files")}
</Button>
{/* Manual file input as backup */}
<input
ref={fileInputRef}
type="file"
multiple={true}
accept={accept.join(',')}
onChange={handleFileInputChange}
style={{ display: 'none' }}
data-testid="file-input"
/>
</Stack>
)}
</Stack>
{/* Recent Files Section */}
{showRecentFiles && recentFiles.length > 0 && (
<Box w="100%" >
<Divider my="md" />
<Text size="lg" fw={500} mb="md">
{t("fileUpload.recentFiles", "Recent Files")}
</Text>
<MultiSelectControls
selectedCount={selectedFiles.length}
onClearSelection={selectionHandlers.clearSelection}
onAddToUpload={handleSelectedRecentFiles}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
<FileGrid
files={recentFiles}
onDoubleClick={handleRecentFileSelection}
onSelect={selectionHandlers.toggleSelection}
onRemove={handleRemoveFileByIndex}
selectedFiles={selectedFiles}
showSearch={true}
showSort={true}
isFileSupported={isFileSupported}
onDeleteAll={async () => {
await Promise.all(recentFiles.map(async (file) => {
await fileStorage.deleteFile(file.id || file.name);
}));
setRecentFiles([]);
setSelectedFiles([]);
}}
/>
</Box>
)}
</Stack>
</>
);
};
export default FileUploadSelector;

View File

@ -1,28 +1,146 @@
import React from 'react';
import { Container, Stack, Text, Button } from '@mantine/core';
import FolderIcon from '@mui/icons-material/FolderRounded';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { Container, Text, Button, Checkbox, Group, useMantineColorScheme } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import AddIcon from '@mui/icons-material/Add';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '../../hooks/useFileHandler';
interface LandingPageProps {
title: string;
const LandingPage = () => {
const { addMultipleFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { colorScheme } = useMantineColorScheme();
const { t } = useTranslation();
const handleFileDrop = async (files: File[]) => {
await addMultipleFiles(files);
};
const handleAddFilesClick = () => {
fileInputRef.current?.click();
};
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length > 0) {
await addMultipleFiles(files);
}
// Reset the input so the same file can be selected again
event.target.value = '';
};
const LandingPage = ({ title }: LandingPageProps) => {
const { openFilesModal } = useFilesModalContext();
return (
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<Stack align="center" gap="lg">
<Text size="xl" fw={500} c="dimmed">
{title}
</Text>
<Button
leftSection={<FolderIcon />}
size="lg"
onClick={openFilesModal}
<Container size="lg" p={0} h="100%" className="flex items-center justify-center" style={{ position: 'relative' }}>
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95vh]"
style={{
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
bottom: 0,
borderRadius: '0.5rem 0.5rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.2s ease',
}}
activateOnClick={false}
styles={{
root: {
'&[data-accept]': {
backgroundColor: 'var(--landing-drop-paper-bg)',
},
},
}}
>
Open Files
<div
style={{
position: 'absolute',
top: 0,
right: ".5rem",
zIndex: 10,
}}
>
<img
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoNoTextDark.svg' : '/branding/StirlingPDFLogoNoTextLight.svg'}
alt="Stirling PDF Logo"
style={{
width: '10rem',
height: 'auto',
pointerEvents: 'none',
marginTop: '-0.5rem'
}}
/>
</div>
<div
className={`min-h-[25vh] flex flex-col items-center justify-center px-8 py-8 w-full min-w-[360px] border transition-all duration-200 dropzone-inner relative`}
style={{
borderRadius: '0.5rem',
backgroundColor: 'var(--landing-inner-paper-bg)',
borderColor: 'var(--landing-inner-paper-border)',
borderWidth: '1px',
borderStyle: 'solid',
}}
>
{/* Logo positioned absolutely in top right corner */}
{/* Centered content container */}
<div className="flex flex-col items-center gap-4 flex-none w-full">
{/* Stirling PDF Branding */}
<Group gap="xs" align="center">
<img
src={colorScheme === 'dark' ? '/branding/StirlingPDFLogoWhiteText.svg' : '/branding/StirlingPDFLogoGreyText.svg'}
alt="Stirling PDF"
style={{ height: '2.2rem', width: 'auto' }}
/>
</Group>
{/* Add Files Button */}
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
width: '80%',
marginTop: '0.8rem',
marginBottom: '0.8rem',
}}
onClick={handleAddFilesClick}
>
<AddIcon className="text-[var(--accent-interactive)]" />
<span>
{t('fileUpload.addFiles', 'Add Files')}
</span>
</Button>
</Stack>
{/* Hidden file input for native file picker */}
<input
ref={fileInputRef}
type="file"
multiple
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
</div>
{/* Instruction Text */}
<span
className="text-[var(--accent-interactive)]"
style={{ fontSize: '.8rem' }}
>
{t('fileUpload.dragFilesInOrClick', 'Drag files in or click "Add Files" to browse')}
</span>
</div>
</Dropzone>
</Container>
);
};

View File

@ -1,4 +1,4 @@
import React, { useState, useRef } from "react";
import React, { useState, useRef, forwardRef } from "react";
import { ActionIcon, Stack, Tooltip, Divider } from "@mantine/core";
import MenuBookIcon from "@mui/icons-material/MenuBookRounded";
import AppsIcon from "@mui/icons-material/AppsRounded";
@ -8,32 +8,12 @@ import FolderIcon from "@mui/icons-material/FolderRounded";
import PersonIcon from "@mui/icons-material/PersonRounded";
import NotificationsIcon from "@mui/icons-material/NotificationsRounded";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
import AppConfigModal from './AppConfigModal';
import { useIsOverflowing } from '../../hooks/useIsOverflowing';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
import { QuickAccessBarProps, ButtonConfig } from '../../types/sidebar';
import './QuickAccessBar.css';
interface QuickAccessBarProps {
onToolsClick: () => void;
onReaderToggle: () => void;
selectedToolKey?: string;
toolRegistry: any;
leftPanelView: 'toolPicker' | 'toolContent';
readerMode: boolean;
}
interface ButtonConfig {
id: string;
name: string;
icon: React.ReactNode;
tooltip: string;
isRound?: boolean;
size?: 'sm' | 'md' | 'lg' | 'xl';
onClick: () => void;
type?: 'navigation' | 'modal' | 'action'; // navigation = main nav, modal = triggers modal, action = other actions
}
function NavHeader({
activeButton,
setActiveButton,
@ -104,14 +84,10 @@ function NavHeader({
);
}
const QuickAccessBar = ({
const QuickAccessBar = forwardRef<HTMLDivElement, QuickAccessBarProps>(({
onToolsClick,
onReaderToggle,
selectedToolKey,
toolRegistry,
leftPanelView,
readerMode,
}: QuickAccessBarProps) => {
}, ref) => {
const { isRainbowMode } = useRainbowThemeContext();
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
const [configModalOpen, setConfigModalOpen] = useState(false);
@ -234,6 +210,8 @@ const QuickAccessBar = ({
return (
<div
ref={ref}
data-sidebar="quick-access"
className={`h-screen flex flex-col w-20 quick-access-bar-main ${isRainbowMode ? 'rainbow-mode' : ''}`}
>
{/* Fixed header outside scrollable area */}
@ -335,6 +313,6 @@ const QuickAccessBar = ({
/>
</div>
);
};
});
export default QuickAccessBar;

View File

@ -0,0 +1,243 @@
import React, { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { isClickOutside, addEventListenerWithCleanup } from '../../utils/genericUtils';
import { useTooltipPosition } from '../../hooks/useTooltipPosition';
import { TooltipContent, TooltipTip } from './tooltip/TooltipContent';
import { useSidebarContext } from '../../contexts/SidebarContext';
import styles from './tooltip/Tooltip.module.css'
export interface TooltipProps {
sidebarTooltip?: boolean;
position?: 'right' | 'left' | 'top' | 'bottom';
content?: React.ReactNode;
tips?: TooltipTip[];
children: React.ReactElement;
offset?: number;
maxWidth?: number | string;
open?: boolean;
onOpenChange?: (open: boolean) => void;
arrow?: boolean;
portalTarget?: HTMLElement;
header?: {
title: string;
logo?: React.ReactNode;
};
}
export const Tooltip: React.FC<TooltipProps> = ({
sidebarTooltip = false,
position = 'right',
content,
tips,
children,
offset: gap = 8,
maxWidth = 280,
open: controlledOpen,
onOpenChange,
arrow = false,
portalTarget,
header,
}) => {
const [internalOpen, setInternalOpen] = useState(false);
const [isPinned, setIsPinned] = useState(false);
const triggerRef = useRef<HTMLElement>(null);
const tooltipRef = useRef<HTMLDivElement>(null);
const hoverTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Get sidebar context for tooltip positioning
const sidebarContext = sidebarTooltip ? useSidebarContext() : null;
// Always use controlled mode - if no controlled props provided, use internal state
const isControlled = controlledOpen !== undefined;
const open = isControlled ? controlledOpen : internalOpen;
const handleOpenChange = (newOpen: boolean) => {
if (isControlled) {
onOpenChange?.(newOpen);
} else {
setInternalOpen(newOpen);
}
// Reset pin state when closing
if (!newOpen) {
setIsPinned(false);
}
};
const handleTooltipClick = (e: React.MouseEvent) => {
e.stopPropagation();
setIsPinned(true);
};
const handleDocumentClick = (e: MouseEvent) => {
// If tooltip is pinned and we click outside of it, unpin it
if (isPinned && isClickOutside(e, tooltipRef.current)) {
setIsPinned(false);
handleOpenChange(false);
}
};
// Use the positioning hook
const { coords, positionReady } = useTooltipPosition({
open,
sidebarTooltip,
position,
gap,
triggerRef,
tooltipRef,
sidebarRefs: sidebarContext?.sidebarRefs,
sidebarState: sidebarContext?.sidebarState
});
// Add document click listener for unpinning
useEffect(() => {
if (isPinned) {
return addEventListenerWithCleanup(document, 'click', handleDocumentClick as EventListener);
}
}, [isPinned]);
const getArrowClass = () => {
// No arrow for sidebar tooltips
if (sidebarTooltip) return null;
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-top";
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
}
};
const getArrowStyleClass = (arrowClass: string) => {
const styleKey = arrowClass.split(' ')[1];
// Handle both kebab-case and camelCase CSS module exports
return styles[styleKey as keyof typeof styles] ||
styles[styleKey.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase()) as keyof typeof styles] ||
'';
};
// Only show tooltip when position is ready and correct
const shouldShowTooltip = open && (sidebarTooltip ? positionReady : true);
const tooltipElement = shouldShowTooltip ? (
<div
ref={tooltipRef}
style={{
position: 'fixed',
top: coords.top,
left: coords.left,
maxWidth,
zIndex: 9999,
visibility: 'visible',
opacity: 1,
color: 'var(--text-primary)',
}}
className={`${styles['tooltip-container']} ${isPinned ? styles.pinned : ''}`}
onClick={handleTooltipClick}
>
{isPinned && (
<button
className={styles['tooltip-pin-button']}
onClick={(e) => {
e.stopPropagation();
setIsPinned(false);
handleOpenChange(false);
}}
title="Close tooltip"
>
<span className="material-symbols-rounded">
close
</span>
</button>
)}
{arrow && getArrowClass() && (
<div
className={`${styles['tooltip-arrow']} ${getArrowStyleClass(getArrowClass()!)}`}
style={coords.arrowOffset !== null ? {
[position === 'top' || position === 'bottom' ? 'left' : 'top']: coords.arrowOffset
} : undefined}
/>
)}
{header && (
<div className={styles['tooltip-header']}>
<div className={styles['tooltip-logo']}>
{header.logo || <img src="/logo-tooltip.svg" alt="Stirling PDF" style={{ width: '1.4rem', height: '1.4rem', display: 'block' }} />}
</div>
<span className={styles['tooltip-title']}>{header.title}</span>
</div>
)}
<TooltipContent
content={content}
tips={tips}
/>
</div>
) : null;
const handleMouseEnter = (e: React.MouseEvent) => {
// Clear any existing timeout
if (hoverTimeoutRef.current) {
clearTimeout(hoverTimeoutRef.current);
hoverTimeoutRef.current = null;
}
// Only show on hover if not pinned
if (!isPinned) {
handleOpenChange(true);
}
(children.props as any)?.onMouseEnter?.(e);
};
const handleMouseLeave = (e: React.MouseEvent) => {
// Only hide on mouse leave if not pinned
if (!isPinned) {
// Add a small delay to prevent flickering
hoverTimeoutRef.current = setTimeout(() => {
handleOpenChange(false);
}, 100);
}
(children.props as any)?.onMouseLeave?.(e);
};
const handleClick = (e: React.MouseEvent) => {
// Toggle pin state on click
if (open) {
setIsPinned(!isPinned);
} else {
handleOpenChange(true);
setIsPinned(true);
}
(children.props as any)?.onClick?.(e);
};
// Take the child element and add tooltip behavior to it
const childWithTooltipHandlers = React.cloneElement(children as any, {
// Keep track of the element for positioning
ref: (node: HTMLElement) => {
triggerRef.current = node;
// Don't break if the child already has a ref
const originalRef = (children as any).ref;
if (typeof originalRef === 'function') {
originalRef(node);
} else if (originalRef && typeof originalRef === 'object') {
originalRef.current = node;
}
},
// Add mouse events to show/hide tooltip
onMouseEnter: handleMouseEnter,
onMouseLeave: handleMouseLeave,
onClick: handleClick,
});
return (
<>
{childWithTooltipHandlers}
{portalTarget && document.body.contains(portalTarget)
? tooltipElement && createPortal(tooltipElement, portalTarget)
: tooltipElement}
</>
);
};

View File

@ -0,0 +1,223 @@
# Tooltip Component
A flexible, accessible tooltip component that supports both regular positioning and special sidebar positioning logic with click-to-pin functionality. The tooltip is controlled by default, appearing on hover and pinning on click.
## Features
- 🎯 **Smart Positioning**: Automatically positions tooltips to stay within viewport bounds
- 📱 **Sidebar Support**: Special positioning logic for sidebar/navigation elements
- ♿ **Accessible**: Works with mouse interactions and click-to-pin functionality
- 🎨 **Customizable**: Support for arrows, structured content, and custom JSX
- 🌙 **Theme Support**: Built-in dark mode and theme variable support
- ⚡ **Performance**: Memoized calculations and efficient event handling
- 📜 **Scrollable**: Content area scrolls when content exceeds max height
- 📌 **Click-to-Pin**: Click to pin tooltips open, click outside or the close button to unpin
- 🔗 **Link Support**: Full support for clickable links in descriptions, bullets, and body content
- 🎮 **Controlled by Default**: Always uses controlled state management for consistent behavior
## Behavior
### Default Behavior (Controlled)
- **Hover**: Tooltips appear on hover with a small delay when leaving to prevent flickering
- **Click**: Click the trigger to pin the tooltip open
- **Click tooltip**: Pins the tooltip to keep it open
- **Click close button**: Unpins and closes the tooltip (red X button in top-right when pinned)
- **Click outside**: Unpins and closes the tooltip
- **Visual indicator**: Pinned tooltips have a blue border and close button
### Manual Control (Optional)
- Use `open` and `onOpenChange` props for complete external control
- Useful for complex state management or custom interaction patterns
## Basic Usage
```tsx
import { Tooltip } from '@/components/shared';
function MyComponent() {
return (
<Tooltip content="This is a helpful tooltip">
<button>Hover me</button>
</Tooltip>
);
}
```
## API Reference
### Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `content` | `ReactNode` | - | Custom JSX content to display in the tooltip |
| `tips` | `TooltipTip[]` | - | Structured content with title, description, bullets, and optional body |
| `children` | `ReactElement` | **required** | Element that triggers the tooltip |
| `sidebarTooltip` | `boolean` | `false` | Enables special sidebar positioning logic |
| `position` | `'right' \| 'left' \| 'top' \| 'bottom'` | `'right'` | Tooltip position (ignored if `sidebarTooltip` is true) |
| `offset` | `number` | `8` | Distance in pixels between trigger and tooltip |
| `maxWidth` | `number \| string` | `280` | Maximum width constraint for the tooltip |
| `open` | `boolean` | `undefined` | External open state (makes component fully controlled) |
| `onOpenChange` | `(open: boolean) => void` | `undefined` | Callback for external control |
| `arrow` | `boolean` | `false` | Shows a small triangular arrow pointing to the trigger element |
| `portalTarget` | `HTMLElement` | `undefined` | DOM node to portal the tooltip into |
| `header` | `{ title: string; logo?: ReactNode }` | - | Optional header with title and logo |
### TooltipTip Interface
```typescript
interface TooltipTip {
title?: string; // Optional pill label
description?: string; // Optional description text (supports HTML including <a> tags)
bullets?: string[]; // Optional bullet points (supports HTML including <a> tags)
body?: React.ReactNode; // Optional custom JSX for this tip
}
```
## Usage Examples
### Default Behavior (Recommended)
```tsx
// Simple tooltip with hover and click-to-pin
<Tooltip content="This tooltip appears on hover and pins on click">
<button>Hover me</button>
</Tooltip>
// Structured content with tips
<Tooltip
tips={[
{
title: "OCR Mode",
description: "Choose how to process text in your documents.",
bullets: [
"<strong>Auto</strong> skips pages that already contain text.",
"<strong>Force</strong> re-processes every page.",
"<strong>Strict</strong> stops if text is found.",
"<a href='https://docs.example.com' target='_blank'>Learn more</a>"
]
}
]}
header={{
title: "Basic Settings Overview",
logo: <img src="/logo.svg" alt="Logo" />
}}
>
<button>Settings</button>
</Tooltip>
```
### Custom JSX Content
```tsx
<Tooltip
content={
<div>
<h3>Custom Content</h3>
<p>Any JSX you want here</p>
<button>Action</button>
<a href="https://example.com">External link</a>
</div>
}
>
<button>Custom tooltip</button>
</Tooltip>
```
### Mixed Content (Tips + Custom JSX)
```tsx
<Tooltip
tips={[
{ title: "Section", description: "Description" }
]}
content={<div>Additional custom content below tips</div>}
>
<button>Mixed content</button>
</Tooltip>
```
### Sidebar Tooltips
```tsx
// For items in a sidebar/navigation
<Tooltip
content="This tooltip appears to the right of the sidebar"
sidebarTooltip={true}
>
<div className="sidebar-item">
📁 File Manager
</div>
</Tooltip>
```
### With Arrows
```tsx
<Tooltip
content="Tooltip with arrow pointing to trigger"
arrow={true}
position="top"
>
<button>Arrow tooltip</button>
</Tooltip>
```
### Manual Control (Advanced)
```tsx
function ManualControlTooltip() {
const [open, setOpen] = useState(false);
return (
<Tooltip
content="Fully controlled tooltip"
open={open}
onOpenChange={setOpen}
>
<button onClick={() => setOpen(!open)}>
Toggle tooltip
</button>
</Tooltip>
);
}
```
## Click-to-Pin Interaction
### How to Use (Default Behavior)
1. **Hover** over the trigger element to show the tooltip
2. **Click** the trigger element to pin the tooltip open
3. **Click** the red X button in the top-right corner to close
4. **Click** anywhere outside the tooltip to close
5. **Click** the trigger again to toggle pin state
### Visual States
- **Unpinned**: Normal tooltip appearance
- **Pinned**: Blue border, subtle glow, and close button (X) in top-right corner
## Link Support
The tooltip fully supports clickable links in all content areas:
- **Descriptions**: Use `<a href="...">` in description strings
- **Bullets**: Use `<a href="...">` in bullet point strings
- **Body**: Use JSX `<a>` elements in the body ReactNode
- **Content**: Use JSX `<a>` elements in custom content
Links automatically get proper styling with hover states and open in new tabs when using `target="_blank"`.
## Positioning Logic
### Regular Tooltips
- Uses the `position` prop to determine initial placement
- Automatically clamps to viewport boundaries
- Calculates optimal position based on trigger element's `getBoundingClientRect()`
- **Dynamic arrow positioning**: Arrow stays aligned with trigger even when tooltip is clamped
### Sidebar Tooltips
- When `sidebarTooltip={true}`, horizontal positioning is locked to the right of the sidebar
- Vertical positioning follows the trigger but clamps to viewport
- **Smart sidebar detection**: Uses `getSidebarInfo()` to determine which sidebar is active (tool panel vs quick access bar) and gets its exact position
- **Dynamic positioning**: Adapts to whether the tool panel is expanded or collapsed
- **Conditional display**: Only shows tooltips when the tool panel is active (`sidebarInfo.isToolPanelActive`)
- **No arrows** - sidebar tooltips don't show arrows

View File

@ -0,0 +1,191 @@
/* Tooltip Container */
.tooltip-container {
position: fixed;
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
background-color: var(--bg-raised);
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05);
font-size: 0.875rem;
line-height: 1.5;
pointer-events: auto;
z-index: 9999;
transition: opacity 100ms ease-out, transform 100ms ease-out;
min-width: 25rem;
max-width: 50vh;
max-height: 80vh;
color: var(--text-primary);
display: flex;
flex-direction: column;
}
/* Pinned tooltip indicator */
.tooltip-container.pinned {
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0.625rem 0.9375rem -0.1875rem rgba(0, 0, 0, 0.1), 0 0.25rem 0.375rem -0.125rem rgba(0, 0, 0, 0.05), 0 0 0 0.125rem rgba(59, 130, 246, 0.1);
}
/* Pinned tooltip header */
.tooltip-container.pinned .tooltip-header {
background-color: var(--primary-color, #3b82f6);
color: white;
border-color: var(--primary-color, #3b82f6);
}
/* Close button */
.tooltip-pin-button {
position: absolute;
top: -0.5rem;
right: 0.5rem;
font-size: 0.875rem;
background: var(--bg-raised);
padding: 0.25rem;
border-radius: 0.25rem;
border: 0.0625rem solid var(--primary-color, #3b82f6);
cursor: pointer;
transition: background-color 0.2s ease, border-color 0.2s ease;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
min-width: 1.5rem;
min-height: 1.5rem;
}
.tooltip-pin-button .material-symbols-outlined {
font-size: 1rem;
line-height: 1;
}
.tooltip-pin-button:hover {
background-color: #ef4444 !important;
border-color: #ef4444 !important;
}
/* Tooltip Header */
.tooltip-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
background-color: var(--tooltip-header-bg);
color: var(--tooltip-header-color);
font-size: 0.875rem;
font-weight: 500;
border-top-left-radius: 0.75rem;
border-top-right-radius: 0.75rem;
margin: -0.0625rem -0.0625rem 0 -0.0625rem;
border: 0.0625rem solid var(--tooltip-border);
flex-shrink: 0;
}
.tooltip-logo {
width: 1rem;
height: 1rem;
display: flex;
align-items: center;
justify-content: center;
}
.tooltip-title {
flex: 1;
}
/* Tooltip Body */
.tooltip-body {
padding: 1rem !important;
color: var(--text-primary) !important;
font-size: 0.875rem !important;
line-height: 1.6 !important;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.tooltip-body * {
color: var(--text-primary) !important;
}
/* Link styling within tooltips */
.tooltip-body a {
color: var(--link-color, #3b82f6) !important;
text-decoration: underline;
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
transition: color 0.2s ease, text-decoration-color 0.2s ease;
}
.tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
}
.tooltip-container .tooltip-body {
color: var(--text-primary) !important;
}
.tooltip-container .tooltip-body * {
color: var(--text-primary) !important;
}
/* Ensure links maintain their styling */
.tooltip-container .tooltip-body a {
color: var(--link-color, #3b82f6) !important;
text-decoration: underline;
text-decoration-color: var(--link-underline-color, rgba(59, 130, 246, 0.3));
}
.tooltip-container .tooltip-body a:hover {
color: var(--link-hover-color, #2563eb) !important;
text-decoration-color: var(--link-hover-underline-color, rgba(37, 99, 235, 0.5));
}
/* Tooltip Arrows */
.tooltip-arrow {
position: absolute;
width: 0.5rem;
height: 0.5rem;
background: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
transform: rotate(45deg);
}
.tooltip-arrow-sidebar {
top: 50%;
left: -0.25rem;
transform: translateY(-50%) rotate(45deg);
border-left: none;
border-bottom: none;
}
.tooltip-arrow-top {
top: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);
border-top: none;
border-left: none;
}
.tooltip-arrow-bottom {
bottom: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);
border-bottom: none;
border-right: none;
}
.tooltip-arrow-left {
right: -0.25rem;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-left: none;
border-bottom: none;
}
.tooltip-arrow-right {
left: -0.25rem;
top: 50%;
transform: translateY(-50%) rotate(45deg);
border-right: none;
border-top: none;
}

View File

@ -0,0 +1,78 @@
import React from 'react';
import styles from './Tooltip.module.css';
export interface TooltipTip {
title?: string;
description?: string;
bullets?: string[];
body?: React.ReactNode;
}
interface TooltipContentProps {
content?: React.ReactNode;
tips?: TooltipTip[];
}
export const TooltipContent: React.FC<TooltipContentProps> = ({
content,
tips,
}) => {
return (
<div
className={`${styles['tooltip-body']}`}
style={{
color: 'var(--text-primary)',
padding: '16px',
fontSize: '14px',
lineHeight: '1.6'
}}
>
<div style={{ color: 'var(--text-primary)' }}>
{tips ? (
<>
{tips.map((tip, index) => (
<div key={index} style={{ marginBottom: index < tips.length - 1 ? '24px' : '0' }}>
{tip.title && (
<div style={{
display: 'inline-block',
backgroundColor: 'var(--tooltip-title-bg)',
color: 'var(--tooltip-title-color)',
padding: '6px 12px',
borderRadius: '16px',
fontSize: '12px',
fontWeight: '600',
marginBottom: '12px'
}}>
{tip.title}
</div>
)}
{tip.description && (
<p style={{ margin: '0 0 12px 0', color: 'var(--text-secondary)', fontSize: '13px' }} dangerouslySetInnerHTML={{ __html: tip.description }} />
)}
{tip.bullets && tip.bullets.length > 0 && (
<ul style={{ margin: '0', paddingLeft: '16px', color: 'var(--text-secondary)', fontSize: '13px' }}>
{tip.bullets.map((bullet, bulletIndex) => (
<li key={bulletIndex} style={{ marginBottom: '6px' }} dangerouslySetInnerHTML={{ __html: bullet }} />
))}
</ul>
)}
{tip.body && (
<div style={{ marginTop: '12px' }}>
{tip.body}
</div>
)}
</div>
))}
{content && (
<div style={{ marginTop: '24px' }}>
{content}
</div>
)}
</>
) : (
content
)}
</div>
</div>
);
};

View File

@ -2,6 +2,8 @@ import React, { createContext, useContext, useMemo, useRef } from 'react';
import { Paper, Text, Stack, Box, Flex } from '@mantine/core';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import ChevronRightIcon from '@mui/icons-material/ChevronRight';
import { Tooltip } from '../../shared/Tooltip';
import { TooltipTip } from '../../shared/tooltip/TooltipContent';
interface ToolStepContextType {
visibleStepCount: number;
@ -20,8 +22,48 @@ export interface ToolStepProps {
completedMessage?: string;
helpText?: string;
showNumber?: boolean;
tooltip?: {
content?: React.ReactNode;
tips?: TooltipTip[];
header?: {
title: string;
logo?: React.ReactNode;
};
};
}
const renderTooltipTitle = (
title: string,
tooltip: ToolStepProps['tooltip'],
isCollapsed: boolean
) => {
if (tooltip && !isCollapsed) {
return (
<Tooltip
content={tooltip.content}
tips={tooltip.tips}
header={tooltip.header}
sidebarTooltip={true}
>
<Flex align="center" gap="xs" onClick={(e) => e.stopPropagation()}>
<Text fw={500} size="lg">
{title}
</Text>
<span className="material-symbols-rounded" style={{ fontSize: '1.2rem', color: 'var(--icon-files-color)' }}>
gpp_maybe
</span>
</Flex>
</Tooltip>
);
}
return (
<Text fw={500} size="lg">
{title}
</Text>
);
};
const ToolStep = ({
title,
isVisible = true,
@ -31,7 +73,8 @@ const ToolStep = ({
children,
completedMessage,
helpText,
showNumber
showNumber,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
@ -70,9 +113,7 @@ const ToolStep = ({
{stepNumber}
</Text>
)}
<Text fw={500} size="lg">
{title}
</Text>
{renderTooltipTitle(title, tooltip, isCollapsed)}
</Flex>
{isCollapsed ? (

View File

@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const CompressTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("compress.tooltip.header.title", "Compress Settings Overview")
},
tips: [
{
title: t("compress.tooltip.description.title", "Description"),
description: t("compress.tooltip.description.text", "Compression is an easy way to reduce your file size. Pick File Size to enter a target size and have us adjust quality for you. Pick Quality to set compression strength manually.")
},
{
title: t("compress.tooltip.qualityAdjustment.title", "Quality Adjustment"),
description: t("compress.tooltip.qualityAdjustment.text", "Drag the slider to adjust the compression strength. Lower values (1-3) preserve quality but result in larger files. Higher values (7-9) shrink the file more but reduce image clarity."),
bullets: [
t("compress.tooltip.qualityAdjustment.bullet1", "Lower values preserve quality"),
t("compress.tooltip.qualityAdjustment.bullet2", "Higher values reduce file size")
]
},
{
title: t("compress.tooltip.grayscale.title", "Grayscale"),
description: t("compress.tooltip.grayscale.text", "Select this option to convert all images to black and white, which can significantly reduce file size especially for scanned PDFs or image-heavy documents.")
}
]
};
};

View File

@ -0,0 +1,36 @@
import { useTranslation } from 'react-i18next';
import { TooltipContent } from '../../types/tips';
export const OcrTips = (): TooltipContent => {
const { t } = useTranslation();
return {
header: {
title: t("ocr.tooltip.header.title", "OCR Settings Overview"),
},
tips: [
{
title: t("ocr.tooltip.mode.title", "OCR Mode"),
description: t("ocr.tooltip.mode.text", "Optical Character Recognition (OCR) helps you turn scanned or screenshotted pages into text you can search, copy, or highlight."),
bullets: [
t("ocr.tooltip.mode.bullet1", "Auto skips pages that already contain text layers."),
t("ocr.tooltip.mode.bullet2", "Force re-OCRs every page and replaces all the text."),
t("ocr.tooltip.mode.bullet3", "Strict halts if any selectable text is found.")
]
},
{
title: t("ocr.tooltip.languages.title", "Languages"),
description: t("ocr.tooltip.languages.text", "Improve OCR accuracy by specifying the expected languages. Choose one or more languages to guide detection.")
},
{
title: t("ocr.tooltip.output.title", "Output"),
description: t("ocr.tooltip.output.text", "Decide how you want the text output formatted:"),
bullets: [
t("ocr.tooltip.output.bullet1", "Searchable PDF embeds text behind the original image."),
t("ocr.tooltip.output.bullet2", "HOCR XML returns a structured machine-readable file."),
t("ocr.tooltip.output.bullet3", "Plain-text sidecar creates a separate .txt file with raw content.")
]
}
]
};
};

View File

@ -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<File[]> => {
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);
// 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]);

View File

@ -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<HTMLInputElement>;
// 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<HTMLInputElement>) => void;
// External props
recentFiles: FileWithUrl[];
isFileSupported: (fileName: string) => boolean;
modalHeight: string;
}
// Create the context
const FileManagerContext = createContext<FileManagerContextValue | null>(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<StoredFile>;
refreshRecentFiles: () => Promise<void>;
}
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
children,
recentFiles,
onFilesSelected,
onClose,
isFileSupported,
isOpen,
onFileRemove,
modalHeight,
storeFile,
refreshRecentFiles,
}) => {
const [activeSource, setActiveSource] = useState<'recent' | 'local' | 'drive'>('recent');
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const fileInputRef = useRef<HTMLInputElement>(null);
// Track blob URLs for cleanup
const createdBlobUrls = useRef<Set<string>>(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<HTMLInputElement>) => {
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 (
<FileManagerContext.Provider value={contextValue}>
{children}
</FileManagerContext.Provider>
);
};
// 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 <FileManagerProvider>.'
);
}
return context;
};
// Export the context for advanced use cases
export { FileManagerContext };

View File

@ -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<FilesModalContextType | null>(null);
export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { addToActiveFiles, addMultipleFiles } = useFileHandler();
const [isFilesModalOpen, setIsFilesModalOpen] = useState(false);
const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>();
const filesModal = useFilesModal({
onFileSelect: addToActiveFiles,
onFilesSelect: addMultipleFiles,
});
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 (
<FilesModalContext.Provider value={filesModal}>
<FilesModalContext.Provider value={contextValue}>
{children}
</FilesModalContext.Provider>
);

View File

@ -0,0 +1,47 @@
import React, { createContext, useContext, useState, useRef } from 'react';
import { SidebarState, SidebarRefs, SidebarContextValue, SidebarProviderProps } from '../types/sidebar';
const SidebarContext = createContext<SidebarContextValue | undefined>(undefined);
export function SidebarProvider({ children }: SidebarProviderProps) {
// All sidebar state management
const quickAccessRef = useRef<HTMLDivElement>(null);
const toolPanelRef = useRef<HTMLDivElement>(null);
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false);
const sidebarState: SidebarState = {
sidebarsVisible,
leftPanelView,
readerMode,
};
const sidebarRefs: SidebarRefs = {
quickAccessRef,
toolPanelRef,
};
const contextValue: SidebarContextValue = {
sidebarState,
sidebarRefs,
setSidebarsVisible,
setLeftPanelView,
setReaderMode,
};
return (
<SidebarContext.Provider value={contextValue}>
{children}
</SidebarContext.Provider>
);
}
export function useSidebarContext(): SidebarContextValue {
const context = useContext(SidebarContext);
if (context === undefined) {
throw new Error('useSidebarContext must be used within a SidebarProvider');
}
return context;
}

View File

@ -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);

View File

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

View File

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

View File

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

View File

@ -0,0 +1,177 @@
import { useState, useEffect, useMemo } from 'react';
import { clamp } from '../utils/genericUtils';
import { getSidebarInfo } from '../utils/sidebarUtils';
import { SidebarRefs, SidebarState } from '../types/sidebar';
type Position = 'right' | 'left' | 'top' | 'bottom';
interface PlacementResult {
top: number;
left: number;
}
interface PositionState {
coords: { top: number; left: number; arrowOffset: number | null };
positionReady: boolean;
}
function place(
triggerRect: DOMRect,
tooltipRect: DOMRect,
position: Position,
offset: number
): PlacementResult {
let top = 0;
let left = 0;
switch (position) {
case 'right':
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
left = triggerRect.right + offset;
break;
case 'left':
top = triggerRect.top + triggerRect.height / 2 - tooltipRect.height / 2;
left = triggerRect.left - tooltipRect.width - offset;
break;
case 'top':
top = triggerRect.top - tooltipRect.height - offset;
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
break;
case 'bottom':
top = triggerRect.bottom + offset;
left = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
break;
}
return { top, left };
}
export function useTooltipPosition({
open,
sidebarTooltip,
position,
gap,
triggerRef,
tooltipRef,
sidebarRefs,
sidebarState
}: {
open: boolean;
sidebarTooltip: boolean;
position: Position;
gap: number;
triggerRef: React.RefObject<HTMLElement | null>;
tooltipRef: React.RefObject<HTMLDivElement | null>;
sidebarRefs?: SidebarRefs;
sidebarState?: SidebarState;
}): PositionState {
const [coords, setCoords] = useState<{ top: number; left: number; arrowOffset: number | null }>({
top: 0,
left: 0,
arrowOffset: null
});
const [positionReady, setPositionReady] = useState(false);
// Fallback sidebar position (only used as last resort)
const sidebarLeft = 240;
const updatePosition = () => {
if (!triggerRef.current || !open) return;
const triggerRect = triggerRef.current.getBoundingClientRect();
let top: number;
let left: number;
let arrowOffset: number | null = null;
if (sidebarTooltip) {
// Require sidebar refs and state for proper positioning
if (!sidebarRefs || !sidebarState) {
console.warn('⚠️ Sidebar tooltip requires sidebarRefs and sidebarState props');
setPositionReady(false);
return;
}
const sidebarInfo = getSidebarInfo(sidebarRefs, sidebarState);
const currentSidebarRight = sidebarInfo.rect ? sidebarInfo.rect.right : sidebarLeft;
// Only show tooltip if we have the tool panel active
if (!sidebarInfo.isToolPanelActive) {
console.log('🚫 Not showing tooltip - tool panel not active');
setPositionReady(false);
return;
}
// Position to the right of active sidebar with 20px gap
left = currentSidebarRight + 20;
top = triggerRect.top; // Align top of tooltip with trigger element
// Only clamp if we have tooltip dimensions
if (tooltipRef.current) {
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const maxTop = window.innerHeight - tooltipRect.height - 4;
const originalTop = top;
top = clamp(top, 4, maxTop);
// If tooltip was clamped, adjust arrow position to stay aligned with trigger
if (originalTop !== top) {
arrowOffset = triggerRect.top + triggerRect.height / 2 - top;
}
}
setCoords({ top, left, arrowOffset });
setPositionReady(true);
} else {
// Regular tooltip logic
if (!tooltipRef.current) return;
const tooltipRect = tooltipRef.current.getBoundingClientRect();
const placement = place(triggerRect, tooltipRect, position, gap);
top = placement.top;
left = placement.left;
// Clamp to viewport
top = clamp(top, 4, window.innerHeight - tooltipRect.height - 4);
left = clamp(left, 4, window.innerWidth - tooltipRect.width - 4);
// Calculate arrow position to stay aligned with trigger
if (position === 'top' || position === 'bottom') {
// For top/bottom arrows, adjust horizontal position
const triggerCenter = triggerRect.left + triggerRect.width / 2;
const tooltipCenter = left + tooltipRect.width / 2;
if (Math.abs(triggerCenter - tooltipCenter) > 4) {
// Arrow needs adjustment
arrowOffset = triggerCenter - left - 4; // 4px is half arrow width
}
} else {
// For left/right arrows, adjust vertical position
const triggerCenter = triggerRect.top + triggerRect.height / 2;
const tooltipCenter = top + tooltipRect.height / 2;
if (Math.abs(triggerCenter - tooltipCenter) > 4) {
// Arrow needs adjustment
arrowOffset = triggerCenter - top - 4; // 4px is half arrow height
}
}
setCoords({ top, left, arrowOffset });
setPositionReady(true);
}
};
useEffect(() => {
if (!open) return;
requestAnimationFrame(updatePosition);
const handleUpdate = () => requestAnimationFrame(updatePosition);
window.addEventListener('scroll', handleUpdate, true);
window.addEventListener('resize', handleUpdate);
return () => {
window.removeEventListener('scroll', handleUpdate, true);
window.removeEventListener('resize', handleUpdate);
};
}, [open, sidebarLeft, position, gap, sidebarTooltip]);
return { coords, positionReady };
}

View File

@ -2,11 +2,13 @@ import React, { useState, useCallback, useEffect, useRef } from "react";
import { useTranslation } from 'react-i18next';
import { useFileContext } from "../contexts/FileContext";
import { FileSelectionProvider, useFileSelection } from "../contexts/FileSelectionContext";
import { SidebarProvider, useSidebarContext } from "../contexts/SidebarContext";
import { useToolManagement } from "../hooks/useToolManagement";
import { useFileHandler } from "../hooks/useFileHandler";
import { Group, Box, Button } from "@mantine/core";
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
import { PageEditorFunctions } from "../types/pageEditor";
import { SidebarRefs, SidebarState } from "../types/sidebar";
import rainbowStyles from '../styles/rainbow.module.css';
import ToolPicker from "../components/tools/ToolPicker";
@ -18,11 +20,22 @@ 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() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
const {
sidebarState,
sidebarRefs,
setSidebarsVisible,
setLeftPanelView,
setReaderMode
} = useSidebarContext();
const { sidebarsVisible, leftPanelView, readerMode } = sidebarState;
const { quickAccessRef, toolPanelRef } = sidebarRefs;
const fileContext = useFileContext();
const { activeFiles, currentView, setCurrentView } = fileContext;
@ -37,9 +50,6 @@ function HomePageContent() {
clearToolSelection,
} = useToolManagement();
const [sidebarsVisible, setSidebarsVisible] = useState(true);
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
const [readerMode, setReaderMode] = useState(false);
const [pageEditorFunctions, setPageEditorFunctions] = useState<PageEditorFunctions | null>(null);
const [previewFile, setPreviewFile] = useState<File | null>(null);
@ -92,16 +102,15 @@ function HomePageContent() {
>
{/* Quick Access Bar */}
<QuickAccessBar
ref={quickAccessRef}
onToolsClick={handleQuickAccessTools}
onReaderToggle={handleReaderToggle}
selectedToolKey={selectedToolKey}
toolRegistry={toolRegistry}
leftPanelView={leftPanelView}
readerMode={readerMode}
/>
{/* Left: Tool Picker or Selected Tool Panel */}
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
style={{
width: sidebarsVisible && !readerMode ? '14vw' : '0',
@ -270,7 +279,7 @@ function HomePageContent() {
</Box>
{/* Global Modals */}
<FileUploadModal selectedTool={selectedTool} />
<FileManager selectedTool={selectedTool} />
</Group>
);
}
@ -279,7 +288,9 @@ function HomePageContent() {
export default function HomePage() {
return (
<FileSelectionProvider>
<SidebarProvider>
<HomePageContent />
</SidebarProvider>
</FileSelectionProvider>
);
}

View File

@ -225,6 +225,32 @@ class FileStorageService {
});
}
/**
* Update the lastModified timestamp of a file (for most recently used sorting)
*/
async touchFile(id: string): Promise<boolean> {
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
*/

View File

@ -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;
@ -103,9 +106,40 @@
--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;
--accent-interactive: #4A90E2;
--text-instruction: #4A90E2;
--text-brand: var(--color-gray-700);
--text-brand-accent: #DC2626;
/* container */
--landing-paper-bg: var(--bg-surface);
--landing-inner-paper-bg: #EEF8FF;
--landing-inner-paper-border: #CDEAFF;
--landing-button-bg: var(--bg-surface);
--landing-button-color: var(--icon-tools-bg);
--landing-button-border: #E0F2F7;
--landing-button-hover-bg: rgb(251, 251, 251);
/* drop state */
--landing-drop-paper-bg: #E3F2FD;
--landing-drop-inner-paper-bg: #BBDEFB;
--landing-drop-inner-paper-border: #90CAF9;
/* shadows */
--drop-shadow-color: rgba(0, 0, 0, 0.08);
--drop-shadow-color-strong: rgba(0, 0, 0, 0.04);
--drop-shadow-filter: drop-shadow(0 0.2rem 0.4rem rgba(0, 0, 0, 0.08)) drop-shadow(0 0.6rem 0.6rem rgba(0, 0, 0, 0.06)) drop-shadow(0 1.2rem 1rem rgba(0, 0, 0, 0.04));
}
[data-mantine-color-scheme="dark"] {
@ -144,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;
@ -177,6 +214,37 @@
--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);
--text-brand-accent: #EF4444;
/* container */
--landing-paper-bg: #171A1F;
--landing-inner-paper-bg: var(--bg-raised);
--landing-inner-paper-border: #2D3237;
--landing-button-bg: #2B3037;
--landing-button-color: #ffffff;
--landing-button-border: #2D3237;
--landing-button-hover-bg: #4c525b;
/* drop state */
--landing-drop-paper-bg: #1A2332;
--landing-drop-inner-paper-bg: #2A3441;
--landing-drop-inner-paper-border: #3A4451;
/* shadows */
--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);
@ -185,6 +253,12 @@
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
}
/* Dropzone drop state styling */
[data-accept] .dropzone-inner {
background-color: var(--landing-drop-inner-paper-bg) !important;
border-color: var(--landing-drop-inner-paper-border) !important;
}
/* Smooth transitions for theme switching */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;

View File

@ -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

View File

@ -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 }) => (

View File

@ -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}
>
<Stack gap="sm">
<CompressSettings

View File

@ -18,6 +18,7 @@ import AdvancedOCRSettings from "../components/tools/ocr/AdvancedOCRSettings";
import { useOCRParameters } from "../hooks/tools/ocr/useOCRParameters";
import { useOCROperation } from "../hooks/tools/ocr/useOCROperation";
import { BaseToolProps } from "../types/tool";
import { OcrTips } from "../components/tooltips/OCRTips";
const OCR = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
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}
>
<Stack gap="sm">
<OCRSettings

View File

@ -0,0 +1,46 @@
export interface SidebarState {
sidebarsVisible: boolean;
leftPanelView: 'toolPicker' | 'toolContent';
readerMode: boolean;
}
export interface SidebarRefs {
quickAccessRef: React.RefObject<HTMLDivElement | null>;
toolPanelRef: React.RefObject<HTMLDivElement | null>;
}
export interface SidebarInfo {
rect: DOMRect | null;
isToolPanelActive: boolean;
sidebarState: SidebarState;
}
// Context-related interfaces
export interface SidebarContextValue {
sidebarState: SidebarState;
sidebarRefs: SidebarRefs;
setSidebarsVisible: React.Dispatch<React.SetStateAction<boolean>>;
setLeftPanelView: React.Dispatch<React.SetStateAction<'toolPicker' | 'toolContent'>>;
setReaderMode: React.Dispatch<React.SetStateAction<boolean>>;
}
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';
}

View File

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

View File

@ -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;
}
/**

View File

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

View File

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

View File

@ -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<string, any> = {
// 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<string | undefined> {
// 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 {