mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-08-06 13:48:58 +02:00
Changed to provider pattern to reduce prop drilling
This commit is contained in:
parent
af1643a5ae
commit
7e18cc275b
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -9,7 +9,7 @@ import { Tool } from '../../types/tool';
|
||||
import MobileLayout from './fileManager/MobileLayout';
|
||||
import DesktopLayout from './fileManager/DesktopLayout';
|
||||
import DragOverlay from './fileManager/DragOverlay';
|
||||
import { FileSource } from './fileManager/types';
|
||||
import { FileManagerProvider } from './fileManager/FileManagerContext';
|
||||
|
||||
interface FileManagerProps {
|
||||
selectedTool?: Tool | null;
|
||||
@ -18,10 +18,6 @@ interface FileManagerProps {
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { t } = useTranslation();
|
||||
const { isFilesModalOpen, closeFilesModal, onFileSelect, onFilesSelect } = useFilesModalContext();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [activeSource, setActiveSource] = useState<FileSource>('recent');
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
|
||||
const [recentFiles, setRecentFiles] = useState<FileWithUrl[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
@ -40,31 +36,21 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const openFileDialog = useCallback(() => {
|
||||
fileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleFileInputChange = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Store files in IndexedDB and get FileWithUrl objects
|
||||
const storedFiles = await Promise.all(
|
||||
files.map(async (file) => {
|
||||
await storeFile(file);
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
onFilesSelect(storedFiles);
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to process uploaded files:', error);
|
||||
}
|
||||
const handleFilesSelected = useCallback(async (files: FileWithUrl[]) => {
|
||||
try {
|
||||
const fileObjects = await Promise.all(
|
||||
files.map(async (fileWithUrl) => {
|
||||
if (fileWithUrl.file) {
|
||||
return fileWithUrl.file;
|
||||
}
|
||||
return await convertToFile(fileWithUrl);
|
||||
})
|
||||
);
|
||||
onFilesSelect(fileObjects);
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
// Clear the input
|
||||
event.target.value = '';
|
||||
}, [storeFile, onFilesSelect, refreshRecentFiles]);
|
||||
}, [convertToFile, onFilesSelect]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
@ -79,69 +65,8 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
}
|
||||
}, [storeFile, onFilesSelect, refreshRecentFiles]);
|
||||
|
||||
const handleRecentFileSelection = useCallback(async (file: FileWithUrl) => {
|
||||
try {
|
||||
const fileObj = await convertToFile(file);
|
||||
if (onFileSelect) {
|
||||
onFileSelect(fileObj);
|
||||
} else {
|
||||
onFilesSelect([fileObj]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select recent file:', error);
|
||||
}
|
||||
}, [onFileSelect, onFilesSelect, convertToFile]);
|
||||
|
||||
// Selection handlers
|
||||
const selectionHandlers = {
|
||||
toggleSelection: (fileId: string) => {
|
||||
setSelectedFileIds(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
},
|
||||
clearSelection: () => setSelectedFileIds([])
|
||||
};
|
||||
|
||||
const selectedFiles = recentFiles.filter(file =>
|
||||
selectedFileIds.includes(file.id || file.name)
|
||||
);
|
||||
|
||||
const filteredFiles = recentFiles.filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleOpenFiles = useCallback(() => {
|
||||
if (selectedFiles.length > 0) {
|
||||
const filesAsFileObjects = selectedFiles.map(fileWithUrl => {
|
||||
const file = new File([], fileWithUrl.name, { type: fileWithUrl.type });
|
||||
Object.defineProperty(file, 'size', { value: fileWithUrl.size || 0 });
|
||||
Object.defineProperty(file, 'lastModified', { value: fileWithUrl.lastModified || Date.now() });
|
||||
return file;
|
||||
});
|
||||
onFilesSelect(filesAsFileObjects);
|
||||
selectionHandlers.clearSelection();
|
||||
}
|
||||
}, [selectedFiles, onFilesSelect, selectionHandlers]);
|
||||
|
||||
const handleFileSelect = useCallback((file: FileWithUrl) => {
|
||||
selectionHandlers.toggleSelection(file.id || file.name);
|
||||
}, [selectionHandlers]);
|
||||
|
||||
const handleFileDoubleClick = useCallback(async (file: FileWithUrl) => {
|
||||
try {
|
||||
const fileObj = await convertToFile(file);
|
||||
onFilesSelect([fileObj]);
|
||||
} catch (error) {
|
||||
console.error('Failed to load file on double-click:', error);
|
||||
}
|
||||
}, [convertToFile, onFilesSelect]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
const file = recentFiles[index];
|
||||
setSelectedFileIds(prev => prev.filter(id => id !== (file.id || file.name)));
|
||||
}, [handleRemoveFile, recentFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
@ -156,13 +81,22 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
refreshRecentFiles();
|
||||
} else {
|
||||
// Reset state when modal is closed
|
||||
setActiveSource('recent');
|
||||
setSearchTerm('');
|
||||
setSelectedFileIds([]);
|
||||
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%' : '60vw';
|
||||
@ -217,47 +151,17 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
inner: { pointerEvents: 'all' }
|
||||
}}
|
||||
>
|
||||
{isMobile ? (
|
||||
<MobileLayout
|
||||
activeSource={activeSource}
|
||||
onSourceChange={setActiveSource}
|
||||
onLocalFileClick={openFileDialog}
|
||||
selectedFiles={selectedFiles}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
recentFiles={recentFiles}
|
||||
filteredFiles={filteredFiles}
|
||||
selectedFileIds={selectedFileIds}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
onFileDoubleClick={handleFileDoubleClick}
|
||||
isFileSupported={isFileSupported}
|
||||
modalHeight={modalHeight}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileInputChange={handleFileInputChange}
|
||||
/>
|
||||
) : (
|
||||
<DesktopLayout
|
||||
activeSource={activeSource}
|
||||
onSourceChange={setActiveSource}
|
||||
onLocalFileClick={openFileDialog}
|
||||
selectedFiles={selectedFiles}
|
||||
onOpenFiles={handleOpenFiles}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
recentFiles={recentFiles}
|
||||
filteredFiles={filteredFiles}
|
||||
selectedFileIds={selectedFileIds}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
onFileDoubleClick={handleFileDoubleClick}
|
||||
isFileSupported={isFileSupported}
|
||||
fileInputRef={fileInputRef}
|
||||
onFileInputChange={handleFileInputChange}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
)}
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
isOpen={isFilesModalOpen}
|
||||
onFileRemove={handleRemoveFileByIndex}
|
||||
modalHeight={modalHeight}
|
||||
>
|
||||
{isMobile ? <MobileLayout /> : <DesktopLayout />}
|
||||
</FileManagerProvider>
|
||||
</Dropzone>
|
||||
|
||||
<DragOverlay isVisible={isDragging} />
|
||||
|
@ -1,54 +1,18 @@
|
||||
import React from 'react';
|
||||
import { Grid, Center, Stack } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
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 { FileSource } from './types';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
interface DesktopLayoutProps {
|
||||
activeSource: FileSource;
|
||||
onSourceChange: (source: FileSource) => void;
|
||||
onLocalFileClick: () => void;
|
||||
selectedFiles: FileWithUrl[];
|
||||
onOpenFiles: () => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
recentFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
selectedFileIds: string[];
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
const DesktopLayout: React.FC<DesktopLayoutProps> = ({
|
||||
activeSource,
|
||||
onSourceChange,
|
||||
onLocalFileClick,
|
||||
selectedFiles,
|
||||
onOpenFiles,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
isFileSupported,
|
||||
fileInputRef,
|
||||
onFileInputChange,
|
||||
modalHeight,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const DesktopLayout: React.FC = () => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
modalHeight,
|
||||
} = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<Grid gutter="md" h="100%" grow={false} style={{ flexWrap: 'nowrap' }}>
|
||||
@ -59,33 +23,17 @@ const DesktopLayout: React.FC<DesktopLayoutProps> = ({
|
||||
flexShrink: 0,
|
||||
height: '100%',
|
||||
}}>
|
||||
<FileSourceButtons
|
||||
activeSource={activeSource}
|
||||
onSourceChange={onSourceChange}
|
||||
onLocalFileClick={onLocalFileClick}
|
||||
/>
|
||||
<FileSourceButtons />
|
||||
</Grid.Col>
|
||||
|
||||
{/* Column 2: File List */}
|
||||
<Grid.Col span="auto" style={{ display: 'flex', flexDirection: 'column', height: '100%', minHeight: 0 }}>
|
||||
{activeSource === 'recent' && (
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
style={{ marginBottom: '1rem', flexShrink: 0 }}
|
||||
/>
|
||||
<SearchInput style={{ marginBottom: '1rem', flexShrink: 0 }} />
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0 }}>
|
||||
<FileListArea
|
||||
activeSource={activeSource}
|
||||
recentFiles={recentFiles}
|
||||
filteredFiles={filteredFiles}
|
||||
selectedFileIds={selectedFileIds}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
isFileSupported={isFileSupported}
|
||||
scrollAreaHeight={`calc(${modalHeight} - 6rem)`}
|
||||
scrollAreaStyle={{
|
||||
height: activeSource === 'recent' && recentFiles.length > 0 ? `calc(${modalHeight} - 6rem)` : '100%'
|
||||
@ -97,19 +45,12 @@ const DesktopLayout: React.FC<DesktopLayoutProps> = ({
|
||||
{/* Column 3: File Details */}
|
||||
<Grid.Col span="content" style={{ minWidth: '20rem', width: '20rem', flexShrink: 0, height: '100%' }}>
|
||||
<div style={{ height: '100%' }}>
|
||||
<FileDetails
|
||||
selectedFiles={selectedFiles}
|
||||
onOpenFiles={onOpenFiles}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
<FileDetails />
|
||||
</div>
|
||||
</Grid.Col>
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput
|
||||
fileInputRef={fileInputRef}
|
||||
onFileInputChange={onFileInputChange}
|
||||
/>
|
||||
<HiddenFileInput />
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
@ -6,77 +6,31 @@ import ChevronRightIcon from '@mui/icons-material/ChevronRight';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { detectFileExtension, getFileSize } from '../../../utils/fileUtils';
|
||||
import { useIndexedDBThumbnail } from '../../../hooks/useIndexedDBThumbnail';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
import { FileDetailsProps } from './types';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
const FileDetails: React.FC<FileDetailsProps & { compact?: boolean; modalHeight?: string }> = ({ selectedFiles, onOpenFiles, compact = false, modalHeight = '80vh' }) => {
|
||||
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 [thumbnailCache, setThumbnailCache] = useState<Record<string, string>>({});
|
||||
const [loadingFile, setLoadingFile] = useState<FileWithUrl | null>(null);
|
||||
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);
|
||||
|
||||
// Only load thumbnail for files not in cache
|
||||
const shouldLoadThumbnail = loadingFile && !thumbnailCache[loadingFile.id || loadingFile.name];
|
||||
const { thumbnail } = useIndexedDBThumbnail(shouldLoadThumbnail ? loadingFile : ({} as FileWithUrl));
|
||||
|
||||
// Load thumbnails for all selected files
|
||||
useEffect(() => {
|
||||
// Start loading thumbnails for uncached files
|
||||
const uncachedFiles = selectedFiles.filter(file => !thumbnailCache[file.id || file.name]);
|
||||
if (uncachedFiles.length > 0 && !loadingFile) {
|
||||
setLoadingFile(uncachedFiles[0]);
|
||||
}
|
||||
}, [selectedFiles, thumbnailCache, loadingFile]);
|
||||
|
||||
// Cache thumbnail when it loads and move to next uncached file
|
||||
useEffect(() => {
|
||||
if (loadingFile && thumbnail) {
|
||||
const fileId = loadingFile.id || loadingFile.name;
|
||||
setThumbnailCache(prev => ({
|
||||
...prev,
|
||||
[fileId]: thumbnail
|
||||
}));
|
||||
|
||||
// Find next uncached file to load
|
||||
const uncachedFiles = selectedFiles.filter(file =>
|
||||
!thumbnailCache[file.id || file.name] &&
|
||||
(file.id || file.name) !== fileId
|
||||
);
|
||||
|
||||
if (uncachedFiles.length > 0) {
|
||||
setLoadingFile(uncachedFiles[0]);
|
||||
} else {
|
||||
setLoadingFile(null);
|
||||
}
|
||||
}
|
||||
}, [loadingFile, thumbnail, selectedFiles, thumbnailCache]);
|
||||
|
||||
// Clear cache when selection changes completely
|
||||
useEffect(() => {
|
||||
const selectedFileIds = selectedFiles.map(f => f.id || f.name);
|
||||
setThumbnailCache(prev => {
|
||||
const newCache: Record<string, string> = {};
|
||||
selectedFileIds.forEach(id => {
|
||||
if (prev[id]) {
|
||||
newCache[id] = prev[id];
|
||||
}
|
||||
});
|
||||
return newCache;
|
||||
});
|
||||
setLoadingFile(null);
|
||||
}, [selectedFiles]);
|
||||
|
||||
// Get thumbnail from cache only
|
||||
// Get thumbnail for current file
|
||||
const getCurrentThumbnail = () => {
|
||||
if (!currentFile) return null;
|
||||
const fileId = currentFile.id || currentFile.name;
|
||||
return thumbnailCache[fileId];
|
||||
return currentThumbnail;
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
@ -372,7 +326,6 @@ const FileDetails: React.FC<FileDetailsProps & { compact?: boolean; modalHeight?
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Action Button */}
|
||||
<Button
|
||||
size="md"
|
||||
onClick={onOpenFiles}
|
||||
|
@ -3,35 +3,28 @@ 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 { FileWithUrl } from '../../../types/file';
|
||||
import FileListItem from './FileListItem';
|
||||
import { FileSource } from './types';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
activeSource: FileSource;
|
||||
recentFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
selectedFileIds: string[];
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
scrollAreaHeight: string;
|
||||
scrollAreaStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
isFileSupported,
|
||||
scrollAreaHeight,
|
||||
scrollAreaStyle = {},
|
||||
}) => {
|
||||
const {
|
||||
activeSource,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
isFileSupported,
|
||||
} = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (activeSource === 'recent') {
|
||||
|
@ -0,0 +1,206 @@
|
||||
import React, { createContext, useContext, useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
import { FileSource } from './types';
|
||||
|
||||
// Type for the context value - now contains everything directly
|
||||
interface FileManagerContextValue {
|
||||
// State
|
||||
activeSource: FileSource;
|
||||
selectedFileIds: string[];
|
||||
searchTerm: string;
|
||||
selectedFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
|
||||
// Handlers
|
||||
onSourceChange: (source: FileSource) => 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;
|
||||
}
|
||||
|
||||
export const FileManagerProvider: React.FC<FileManagerProviderProps> = ({
|
||||
children,
|
||||
recentFiles,
|
||||
onFilesSelected,
|
||||
onClose,
|
||||
isFileSupported,
|
||||
isOpen,
|
||||
onFileRemove,
|
||||
modalHeight,
|
||||
}) => {
|
||||
const [activeSource, setActiveSource] = useState<FileSource>('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));
|
||||
const filteredFiles = (recentFiles || []).filter(file =>
|
||||
file.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
const handleSourceChange = useCallback((source: FileSource) => {
|
||||
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((event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const fileWithUrls = files.map(file => {
|
||||
const url = URL.createObjectURL(file);
|
||||
createdBlobUrls.current.add(url);
|
||||
return {
|
||||
id: `local-${Date.now()}-${Math.random()}`,
|
||||
name: file.name,
|
||||
file,
|
||||
url,
|
||||
size: file.size,
|
||||
lastModified: file.lastModified,
|
||||
};
|
||||
});
|
||||
onFilesSelected(fileWithUrls);
|
||||
onClose();
|
||||
}
|
||||
event.target.value = '';
|
||||
}, [onFilesSelected, 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 };
|
@ -4,14 +4,16 @@ 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 { FileSourceButtonsProps } from './types';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps & { horizontal?: boolean }> = ({
|
||||
activeSource,
|
||||
onSourceChange,
|
||||
onLocalFileClick,
|
||||
interface FileSourceButtonsProps {
|
||||
horizontal?: boolean;
|
||||
}
|
||||
|
||||
const FileSourceButtons: React.FC<FileSourceButtonsProps> = ({
|
||||
horizontal = false
|
||||
}) => {
|
||||
const { activeSource, onSourceChange, onLocalFileClick } = useFileManagerContext();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const buttonProps = {
|
||||
|
@ -1,17 +1,15 @@
|
||||
import React from 'react';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
interface HiddenFileInputProps {
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
const HiddenFileInput: React.FC = () => {
|
||||
const { fileInputRef, onFileInputChange } = useFileManagerContext();
|
||||
|
||||
const HiddenFileInput: React.FC<HiddenFileInputProps> = ({ fileInputRef, onFileInputChange }) => {
|
||||
return (
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple={true}
|
||||
accept={["*/*"].join(',')}
|
||||
accept="*/*"
|
||||
onChange={onFileInputChange}
|
||||
style={{ display: 'none' }}
|
||||
data-testid="file-input"
|
||||
|
@ -1,107 +1,47 @@
|
||||
import React from 'react';
|
||||
import { Stack, Box } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { FileWithUrl } from '../../../types/file';
|
||||
import FileSourceButtons from './FileSourceButtons';
|
||||
import FileDetails from './FileDetails';
|
||||
import SearchInput from './SearchInput';
|
||||
import FileListArea from './FileListArea';
|
||||
import HiddenFileInput from './HiddenFileInput';
|
||||
import { FileSource } from './types';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
interface MobileLayoutProps {
|
||||
activeSource: FileSource;
|
||||
onSourceChange: (source: FileSource) => void;
|
||||
onLocalFileClick: () => void;
|
||||
selectedFiles: FileWithUrl[];
|
||||
onOpenFiles: () => void;
|
||||
searchTerm: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
recentFiles: FileWithUrl[];
|
||||
filteredFiles: FileWithUrl[];
|
||||
selectedFileIds: string[];
|
||||
onFileSelect: (file: FileWithUrl) => void;
|
||||
onFileRemove: (index: number) => void;
|
||||
onFileDoubleClick: (file: FileWithUrl) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
modalHeight: string;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
onFileInputChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
const MobileLayout: React.FC<MobileLayoutProps> = ({
|
||||
activeSource,
|
||||
onSourceChange,
|
||||
onLocalFileClick,
|
||||
selectedFiles,
|
||||
onOpenFiles,
|
||||
searchTerm,
|
||||
onSearchChange,
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFileIds,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onFileDoubleClick,
|
||||
isFileSupported,
|
||||
modalHeight,
|
||||
fileInputRef,
|
||||
onFileInputChange,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const MobileLayout: React.FC = () => {
|
||||
const {
|
||||
activeSource,
|
||||
selectedFiles,
|
||||
modalHeight,
|
||||
} = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<Stack h="100%" gap="sm" p="sm">
|
||||
{/* Section 1: File Sources - Fixed at top */}
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileSourceButtons
|
||||
activeSource={activeSource}
|
||||
onSourceChange={onSourceChange}
|
||||
onLocalFileClick={onLocalFileClick}
|
||||
horizontal={true}
|
||||
/>
|
||||
<FileSourceButtons horizontal={true} />
|
||||
</Box>
|
||||
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileDetails
|
||||
selectedFiles={selectedFiles}
|
||||
onOpenFiles={onOpenFiles}
|
||||
compact={true}
|
||||
modalHeight={modalHeight}
|
||||
/>
|
||||
<FileDetails compact={true} />
|
||||
</Box>
|
||||
|
||||
{/* Section 3: Search Bar - Fixed above file list */}
|
||||
{activeSource === 'recent' && (
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<SearchInput
|
||||
value={searchTerm}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
<SearchInput />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Section 4: File List - Fixed height scrollable area */}
|
||||
<Box style={{ flexShrink: 0 }}>
|
||||
<FileListArea
|
||||
activeSource={activeSource}
|
||||
recentFiles={recentFiles}
|
||||
filteredFiles={filteredFiles}
|
||||
selectedFileIds={selectedFileIds}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
isFileSupported={isFileSupported}
|
||||
scrollAreaHeight={`calc(${modalHeight} - ${selectedFiles.length > 0 ? '300px' : '200px'})`}
|
||||
scrollAreaStyle={{ maxHeight: '400px', minHeight: '150px' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Hidden file input for local file selection */}
|
||||
<HiddenFileInput
|
||||
fileInputRef={fileInputRef}
|
||||
onFileInputChange={onFileInputChange}
|
||||
/>
|
||||
<HiddenFileInput />
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
@ -2,22 +2,22 @@ import React from 'react';
|
||||
import { TextInput } from '@mantine/core';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileManagerContext } from './FileManagerContext';
|
||||
|
||||
interface SearchInputProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const SearchInput: React.FC<SearchInputProps> = ({ value, onChange, style }) => {
|
||||
const SearchInput: React.FC<SearchInputProps> = ({ style }) => {
|
||||
const { t } = useTranslation();
|
||||
const { searchTerm, onSearchChange } = useFileManagerContext();
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
placeholder={t('fileManager.searchFiles', 'Search files...')}
|
||||
leftSection={<SearchIcon />}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
style={style}
|
||||
/>
|
||||
);
|
||||
|
@ -11,13 +11,3 @@ export interface FileListItemProps {
|
||||
onDoubleClick?: () => void;
|
||||
}
|
||||
|
||||
export interface FileDetailsProps {
|
||||
selectedFiles: FileWithUrl[];
|
||||
onOpenFiles: () => void;
|
||||
}
|
||||
|
||||
export interface FileSourceButtonsProps {
|
||||
activeSource: FileSource;
|
||||
onSourceChange: (source: FileSource) => void;
|
||||
onLocalFileClick: () => void;
|
||||
}
|
Loading…
Reference in New Issue
Block a user