mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-13 02:18:16 +01:00
Merge branch 'V2' into feature/v2/embed-pdf
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import { Modal } from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { FileMetadata } from '../types/file';
|
||||
import { StirlingFileStub } from '../types/fileContext';
|
||||
import { useFileManager } from '../hooks/useFileManager';
|
||||
import { useFilesModalContext } from '../contexts/FilesModalContext';
|
||||
import { Tool } from '../types/tool';
|
||||
@@ -15,12 +15,12 @@ interface FileManagerProps {
|
||||
}
|
||||
|
||||
const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
const { isFilesModalOpen, closeFilesModal, onFilesSelect, onStoredFilesSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>([]);
|
||||
const { isFilesModalOpen, closeFilesModal, onFileUpload, onRecentFileSelect } = useFilesModalContext();
|
||||
const [recentFiles, setRecentFiles] = useState<StirlingFileStub[]>([]);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
const { loadRecentFiles, handleRemoveFile, convertToFile } = useFileManager();
|
||||
const { loadRecentFiles, handleRemoveFile } = useFileManager();
|
||||
|
||||
// File management handlers
|
||||
const isFileSupported = useCallback((fileName: string) => {
|
||||
@@ -34,33 +34,26 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
setRecentFiles(files);
|
||||
}, [loadRecentFiles]);
|
||||
|
||||
const handleFilesSelected = useCallback(async (files: FileMetadata[]) => {
|
||||
const handleRecentFilesSelected = useCallback(async (files: StirlingFileStub[]) => {
|
||||
try {
|
||||
// Use stored files flow that preserves original IDs
|
||||
const filesWithMetadata = await Promise.all(
|
||||
files.map(async (metadata) => ({
|
||||
file: await convertToFile(metadata),
|
||||
originalId: metadata.id,
|
||||
metadata
|
||||
}))
|
||||
);
|
||||
onStoredFilesSelect(filesWithMetadata);
|
||||
// Use StirlingFileStubs directly - preserves all metadata!
|
||||
onRecentFileSelect(files);
|
||||
} catch (error) {
|
||||
console.error('Failed to process selected files:', error);
|
||||
}
|
||||
}, [convertToFile, onStoredFilesSelect]);
|
||||
}, [onRecentFileSelect]);
|
||||
|
||||
const handleNewFileUpload = useCallback(async (files: File[]) => {
|
||||
if (files.length > 0) {
|
||||
try {
|
||||
// Files will get IDs assigned through onFilesSelect -> FileContext addFiles
|
||||
onFilesSelect(files);
|
||||
onFileUpload(files);
|
||||
await refreshRecentFiles();
|
||||
} catch (error) {
|
||||
console.error('Failed to process dropped files:', error);
|
||||
}
|
||||
}
|
||||
}, [onFilesSelect, refreshRecentFiles]);
|
||||
}, [onFileUpload, refreshRecentFiles]);
|
||||
|
||||
const handleRemoveFileByIndex = useCallback(async (index: number) => {
|
||||
await handleRemoveFile(index, recentFiles, setRecentFiles);
|
||||
@@ -85,7 +78,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
// Cleanup any blob URLs when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// FileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// StoredFileMetadata doesn't have blob URLs, so no cleanup needed
|
||||
// Blob URLs are managed by FileContext and tool operations
|
||||
console.log('FileManager unmounting - FileContext handles blob URL cleanup');
|
||||
};
|
||||
@@ -146,7 +139,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
>
|
||||
<FileManagerProvider
|
||||
recentFiles={recentFiles}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
onRecentFilesSelected={handleRecentFilesSelected}
|
||||
onNewFilesSelect={handleNewFileUpload}
|
||||
onClose={closeFilesModal}
|
||||
isFileSupported={isFileSupported}
|
||||
|
||||
@@ -11,6 +11,8 @@ import FileEditorThumbnail from './FileEditorThumbnail';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
import { FileId, StirlingFile } from '../../types/fileContext';
|
||||
import { downloadBlob } from '../../utils/downloadUtils';
|
||||
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: () => void;
|
||||
@@ -78,22 +80,6 @@ const FileEditor = ({
|
||||
// Use activeStirlingFileStubs directly - no conversion needed
|
||||
const localSelectedIds = contextSelectedIds;
|
||||
|
||||
// Helper to convert StirlingFileStub to FileThumbnail format
|
||||
const recordToFileItem = useCallback((record: any) => {
|
||||
const file = selectors.getFile(record.id);
|
||||
if (!file) return null;
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
name: file.name,
|
||||
pageCount: record.processedFile?.totalPages || 1,
|
||||
thumbnail: record.thumbnailUrl || '',
|
||||
size: file.size,
|
||||
file: file
|
||||
};
|
||||
}, [selectors]);
|
||||
|
||||
|
||||
// Process uploaded files using context
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setError(null);
|
||||
@@ -294,7 +280,6 @@ const FileEditor = ({
|
||||
const handleDeleteFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
|
||||
if (record && file) {
|
||||
// Remove file from context but keep in storage (close, don't delete)
|
||||
const contextFileId = record.id;
|
||||
@@ -306,6 +291,14 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleDownloadFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, setStatus]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
if (record) {
|
||||
@@ -404,13 +397,10 @@ const FileEditor = ({
|
||||
}}
|
||||
>
|
||||
{activeStirlingFileStubs.map((record, index) => {
|
||||
const fileItem = recordToFileItem(record);
|
||||
if (!fileItem) return null;
|
||||
|
||||
return (
|
||||
<FileEditorThumbnail
|
||||
key={record.id}
|
||||
file={fileItem}
|
||||
file={record}
|
||||
index={index}
|
||||
totalFiles={activeStirlingFileStubs.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
@@ -420,8 +410,9 @@ const FileEditor = ({
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
onDownloadFile={handleDownloadFile}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(fileItem.name)}
|
||||
isSupported={isFileSupported(record.name)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -8,22 +8,18 @@ import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
import styles from './FileEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
import { FileId } from '../../types/file';
|
||||
import { formatFileSize } from '../../utils/fileUtils';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string | null;
|
||||
size: number;
|
||||
modifiedAt?: number | string | Date;
|
||||
}
|
||||
|
||||
interface FileEditorThumbnailProps {
|
||||
file: FileItem;
|
||||
file: StirlingFileStub;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: FileId[];
|
||||
@@ -33,7 +29,7 @@ interface FileEditorThumbnailProps {
|
||||
onViewFile: (fileId: FileId) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
|
||||
onDownloadFile?: (fileId: FileId) => void;
|
||||
onDownloadFile: (fileId: FileId) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@@ -64,29 +60,8 @@ const FileEditorThumbnail = ({
|
||||
}, [activeFiles, file.id]);
|
||||
const isPinned = actualFile ? isFilePinned(actualFile) : false;
|
||||
|
||||
const downloadSelectedFile = useCallback(() => {
|
||||
// Prefer parent-provided handler if available
|
||||
if (typeof onDownloadFile === 'function') {
|
||||
onDownloadFile(file.id);
|
||||
return;
|
||||
}
|
||||
const pageCount = file.processedFile?.totalPages || 0;
|
||||
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(maybeFile);
|
||||
link.download = maybeFile.name || file.name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we can't find a way to download, surface a status message
|
||||
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
|
||||
}, [file, onDownloadFile, onSetStatus, t]);
|
||||
const handleRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
// ---- Selection ----
|
||||
@@ -94,12 +69,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
// ---- Meta formatting ----
|
||||
const prettySize = useMemo(() => {
|
||||
const bytes = file.size ?? 0;
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
return formatFileSize(file.size);
|
||||
}, [file.size]);
|
||||
|
||||
const extUpper = useMemo(() => {
|
||||
@@ -109,22 +79,21 @@ const FileEditorThumbnail = ({
|
||||
|
||||
const pageLabel = useMemo(
|
||||
() =>
|
||||
file.pageCount > 0
|
||||
? `${file.pageCount} ${file.pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
pageCount > 0
|
||||
? `${pageCount} ${pageCount === 1 ? 'Page' : 'Pages'}`
|
||||
: '',
|
||||
[file.pageCount]
|
||||
[pageCount]
|
||||
);
|
||||
|
||||
const dateLabel = useMemo(() => {
|
||||
const d =
|
||||
file.modifiedAt != null ? new Date(file.modifiedAt) : new Date(); // fallback
|
||||
const d = new Date(file.lastModified);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
}).format(d);
|
||||
}, [file.modifiedAt]);
|
||||
}, [file.lastModified]);
|
||||
|
||||
// ---- Drag & drop wiring ----
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
@@ -309,7 +278,7 @@ const FileEditorThumbnail = ({
|
||||
|
||||
<button
|
||||
className={styles.actionRow}
|
||||
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
|
||||
onClick={() => { onDownloadFile(file.id); setShowActions(false); }}
|
||||
>
|
||||
<DownloadOutlinedIcon fontSize="small" />
|
||||
<span>{t('download', 'Download')}</span>
|
||||
@@ -350,7 +319,8 @@ const FileEditorThumbnail = ({
|
||||
lineClamp={3}
|
||||
title={`${extUpper || 'FILE'} • ${prettySize}`}
|
||||
>
|
||||
{/* e.g., Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{/* e.g., v2 - Jan 29, 2025 - PDF file - 3 Pages */}
|
||||
{`v${file.versionNumber} - `}
|
||||
{dateLabel}
|
||||
{extUpper ? ` - ${extUpper} file` : ''}
|
||||
{pageLabel ? ` - ${pageLabel}` : ''}
|
||||
@@ -360,9 +330,9 @@ const FileEditorThumbnail = ({
|
||||
{/* Preview area */}
|
||||
<div className={`${styles.previewBox} mx-6 mb-4 relative flex-1`}>
|
||||
<div className={styles.previewPaper}>
|
||||
{file.thumbnail && (
|
||||
{file.thumbnailUrl && (
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
src={file.thumbnailUrl}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
loading="lazy"
|
||||
@@ -399,6 +369,29 @@ const FileEditorThumbnail = ({
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</span>
|
||||
|
||||
{/* Tool chain display at bottom */}
|
||||
{file.toolHistory && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
left: '4px',
|
||||
right: '4px',
|
||||
padding: '4px 6px',
|
||||
textAlign: 'center',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
maxWidth={'100%'}
|
||||
color='var(--mantine-color-gray-7)'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,12 +5,12 @@ 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 { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
|
||||
interface CompactFileDetailsProps {
|
||||
currentFile: FileMetadata | null;
|
||||
currentFile: StirlingFileStub | null;
|
||||
thumbnail: string | null;
|
||||
selectedFiles: FileMetadata[];
|
||||
selectedFiles: StirlingFileStub[];
|
||||
currentFileIndex: number;
|
||||
numberOfFiles: number;
|
||||
isAnimating: boolean;
|
||||
@@ -72,12 +72,19 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile ? getFileSize(currentFile) : ''}
|
||||
{selectedFiles.length > 1 && ` • ${selectedFiles.length} files`}
|
||||
{currentFile && ` • v${currentFile.versionNumber || 1}`}
|
||||
</Text>
|
||||
{hasMultipleFiles && (
|
||||
<Text size="xs" c="blue">
|
||||
{currentFileIndex + 1} of {selectedFiles.length}
|
||||
</Text>
|
||||
)}
|
||||
{/* Compact tool chain for mobile */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{currentFile.toolHistory.map((tool: any) => tool.toolName).join(' → ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Navigation arrows for multiple files */}
|
||||
|
||||
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
68
frontend/src/components/fileManager/FileHistoryGroup.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Box, Text, Collapse, Group } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import FileListItem from './FileListItem';
|
||||
|
||||
interface FileHistoryGroupProps {
|
||||
leafFile: StirlingFileStub;
|
||||
historyFiles: StirlingFileStub[];
|
||||
isExpanded: boolean;
|
||||
onDownloadSingle: (file: StirlingFileStub) => void;
|
||||
onFileDoubleClick: (file: StirlingFileStub) => void;
|
||||
onHistoryFileRemove: (file: StirlingFileStub) => void;
|
||||
isFileSupported: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
const FileHistoryGroup: React.FC<FileHistoryGroupProps> = ({
|
||||
leafFile,
|
||||
historyFiles,
|
||||
isExpanded,
|
||||
onDownloadSingle,
|
||||
onFileDoubleClick,
|
||||
onHistoryFileRemove,
|
||||
isFileSupported,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Sort history files by version number (oldest first, excluding the current leaf file)
|
||||
const sortedHistory = historyFiles
|
||||
.filter(file => file.id !== leafFile.id) // Exclude the leaf file itself
|
||||
.sort((a, b) => (b.versionNumber || 1) - (a.versionNumber || 1));
|
||||
|
||||
if (!isExpanded || sortedHistory.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Collapse in={isExpanded}>
|
||||
<Box ml="md" mt="xs" mb="sm">
|
||||
<Group align="center" mb="sm">
|
||||
<Text size="xs" fw={600} c="dimmed">
|
||||
{t('fileManager.fileHistory', 'File History')} ({sortedHistory.length})
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
<Box ml="md">
|
||||
{sortedHistory.map((historyFile, _index) => (
|
||||
<FileListItem
|
||||
key={`history-${historyFile.id}-${historyFile.versionNumber || 1}`}
|
||||
file={historyFile}
|
||||
isSelected={false} // History files are not selectable
|
||||
isSupported={isFileSupported(historyFile.name)}
|
||||
onSelect={() => {}} // No selection for history files
|
||||
onRemove={() => onHistoryFileRemove(historyFile)} // Remove specific history file
|
||||
onDownload={() => onDownloadSingle(historyFile)}
|
||||
onDoubleClick={() => onFileDoubleClick(historyFile)}
|
||||
isHistoryFile={true} // This enables "Add to Recents" in menu
|
||||
isLatestVersion={false} // History files are never latest
|
||||
// onAddToRecents is accessed from context by FileListItem
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileHistoryGroup;
|
||||
@@ -2,10 +2,11 @@ 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 { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileInfoCardProps {
|
||||
currentFile: FileMetadata | null;
|
||||
currentFile: StirlingFileStub | null;
|
||||
modalHeight: string;
|
||||
}
|
||||
|
||||
@@ -53,11 +54,36 @@ const FileInfoCard: React.FC<FileInfoCardProps> = ({
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
<Text size="sm" c="dimmed">{t('fileManager.lastModified', 'Last Modified')}</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{currentFile ? '1.0' : ''}
|
||||
{currentFile ? new Date(currentFile.lastModified).toLocaleDateString() : ''}
|
||||
</Text>
|
||||
</Group>
|
||||
<Divider />
|
||||
|
||||
<Group justify="space-between" py="xs">
|
||||
<Text size="sm" c="dimmed">{t('fileManager.fileVersion', 'Version')}</Text>
|
||||
{currentFile &&
|
||||
<Badge size="sm" variant="light" color={currentFile?.versionNumber ? 'blue' : 'gray'}>
|
||||
v{currentFile ? (currentFile.versionNumber || 1) : ''}
|
||||
</Badge>}
|
||||
|
||||
</Group>
|
||||
|
||||
{/* Tool Chain Display */}
|
||||
{currentFile?.toolHistory && currentFile.toolHistory.length > 0 && (
|
||||
<>
|
||||
<Divider />
|
||||
<Box py="xs">
|
||||
<Text size="xs" c="dimmed" mb="xs">{t('fileManager.toolChain', 'Tools Applied')}</Text>
|
||||
<ToolChain
|
||||
toolChain={currentFile.toolHistory}
|
||||
displayStyle="badges"
|
||||
size="xs"
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
</Card>
|
||||
|
||||
@@ -4,6 +4,7 @@ import CloudIcon from '@mui/icons-material/Cloud';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileListItem from './FileListItem';
|
||||
import FileHistoryGroup from './FileHistoryGroup';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
|
||||
interface FileListAreaProps {
|
||||
@@ -20,8 +21,11 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
recentFiles,
|
||||
filteredFiles,
|
||||
selectedFilesSet,
|
||||
expandedFileIds,
|
||||
loadedHistoryFiles,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
onHistoryFileRemove,
|
||||
onFileDoubleClick,
|
||||
onDownloadSingle,
|
||||
isFileSupported,
|
||||
@@ -50,18 +54,37 @@ const FileListArea: React.FC<FileListAreaProps> = ({
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
filteredFiles.map((file, index) => (
|
||||
<FileListItem
|
||||
key={file.id}
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
/>
|
||||
))
|
||||
filteredFiles.map((file, index) => {
|
||||
// All files in filteredFiles are now leaf files only
|
||||
const historyFiles = loadedHistoryFiles.get(file.id) || [];
|
||||
const isExpanded = expandedFileIds.has(file.id);
|
||||
|
||||
return (
|
||||
<React.Fragment key={file.id}>
|
||||
<FileListItem
|
||||
file={file}
|
||||
isSelected={selectedFilesSet.has(file.id)}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
onSelect={(shiftKey) => onFileSelect(file, index, shiftKey)}
|
||||
onRemove={() => onFileRemove(index)}
|
||||
onDownload={() => onDownloadSingle(file)}
|
||||
onDoubleClick={() => onFileDoubleClick(file)}
|
||||
isHistoryFile={false} // All files here are leaf files
|
||||
isLatestVersion={true} // All files here are the latest versions
|
||||
/>
|
||||
|
||||
<FileHistoryGroup
|
||||
leafFile={file}
|
||||
historyFiles={historyFiles}
|
||||
isExpanded={isExpanded}
|
||||
onDownloadSingle={onDownloadSingle}
|
||||
onFileDoubleClick={onFileDoubleClick}
|
||||
onHistoryFileRemove={onHistoryFileRemove}
|
||||
isFileSupported={isFileSupported}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -3,12 +3,16 @@ import { Group, Box, Text, ActionIcon, Checkbox, Divider, Menu, Badge } from '@m
|
||||
import MoreVertIcon from '@mui/icons-material/MoreVert';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import DownloadIcon from '@mui/icons-material/Download';
|
||||
import HistoryIcon from '@mui/icons-material/History';
|
||||
import RestoreIcon from '@mui/icons-material/Restore';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { FileId, StirlingFileStub } from '../../types/fileContext';
|
||||
import { useFileManagerContext } from '../../contexts/FileManagerContext';
|
||||
import ToolChain from '../shared/ToolChain';
|
||||
|
||||
interface FileListItemProps {
|
||||
file: FileMetadata;
|
||||
file: StirlingFileStub;
|
||||
isSelected: boolean;
|
||||
isSupported: boolean;
|
||||
onSelect: (shiftKey?: boolean) => void;
|
||||
@@ -16,6 +20,8 @@ interface FileListItemProps {
|
||||
onDownload?: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
isLast?: boolean;
|
||||
isHistoryFile?: boolean; // Whether this is a history file (indented)
|
||||
isLatestVersion?: boolean; // Whether this is the latest version (shows chevron)
|
||||
}
|
||||
|
||||
const FileListItem: React.FC<FileListItemProps> = ({
|
||||
@@ -25,60 +31,89 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
onSelect,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onDoubleClick
|
||||
onDoubleClick,
|
||||
isHistoryFile = false,
|
||||
isLatestVersion = false
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onAddToRecents } = useFileManagerContext();
|
||||
|
||||
// Keep item in hovered state if menu is open
|
||||
const shouldShowHovered = isHovered || isMenuOpen;
|
||||
|
||||
// Get version information for this file
|
||||
const leafFileId = (isLatestVersion ? file.id : (file.originalFileId || file.id)) as FileId;
|
||||
const hasVersionHistory = (file.versionNumber || 1) > 1; // Show history for any processed file (v2+)
|
||||
const currentVersion = file.versionNumber || 1; // Display original files as v1
|
||||
const isExpanded = expandedFileIds.has(leafFileId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box
|
||||
p="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
backgroundColor: isSelected ? 'var(--mantine-color-gray-1)' : (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
cursor: isHistoryFile ? 'default' : 'pointer',
|
||||
backgroundColor: isSelected
|
||||
? 'var(--mantine-color-gray-1)'
|
||||
: (shouldShowHovered ? 'var(--mantine-color-gray-1)' : 'var(--bg-file-list)'),
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
transition: 'background-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none'
|
||||
msUserSelect: 'none',
|
||||
paddingLeft: isHistoryFile ? '2rem' : '0.75rem', // Indent history files
|
||||
borderLeft: isHistoryFile ? '3px solid var(--mantine-color-blue-4)' : 'none' // Visual indicator for history
|
||||
}}
|
||||
onClick={(e) => onSelect(e.shiftKey)}
|
||||
onClick={isHistoryFile ? undefined : (e) => onSelect(e.shiftKey)}
|
||||
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>
|
||||
{!isHistoryFile && (
|
||||
<Box>
|
||||
{/* Checkbox for regular files only */}
|
||||
<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 }}>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ flex: 1 }}>{file.name}</Text>
|
||||
{file.isDraft && (
|
||||
<Badge size="xs" variant="light" color="orange">
|
||||
DRAFT
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color={"blue"}>
|
||||
v{currentVersion}
|
||||
</Badge>
|
||||
|
||||
</Group>
|
||||
<Group gap="xs" align="center">
|
||||
<Text size="xs" c="dimmed">
|
||||
{getFileSize(file)} • {getFileDate(file)}
|
||||
</Text>
|
||||
|
||||
{/* Tool chain for processed files */}
|
||||
{file.toolHistory && file.toolHistory.length > 0 && (
|
||||
<ToolChain
|
||||
toolChain={file.toolHistory}
|
||||
maxWidth={'150px'}
|
||||
displayStyle="text"
|
||||
size="xs"
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Three dots menu - fades in/out on hover */}
|
||||
@@ -117,6 +152,46 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
{t('fileManager.download', 'Download')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{/* Show/Hide History option for latest version files */}
|
||||
{isLatestVersion && hasVersionHistory && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={
|
||||
<HistoryIcon style={{ fontSize: 16 }} />
|
||||
}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleExpansion(leafFileId);
|
||||
}}
|
||||
>
|
||||
{
|
||||
(isExpanded ?
|
||||
t('fileManager.hideHistory', 'Hide History') :
|
||||
t('fileManager.showHistory', 'Show History')
|
||||
)
|
||||
}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Restore option for history files */}
|
||||
{isHistoryFile && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<RestoreIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddToRecents(file);
|
||||
}}
|
||||
>
|
||||
{t('fileManager.restore', 'Restore')}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -42,7 +42,7 @@ export default function Workbench() {
|
||||
// Get tool registry to look up selected tool
|
||||
const { toolRegistry } = useToolManagement();
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addToActiveFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
@@ -81,7 +81,7 @@ export default function Workbench() {
|
||||
setCurrentView("pageEditor");
|
||||
},
|
||||
onMergeFiles: (filesToMerge) => {
|
||||
filesToMerge.forEach(addToActiveFiles);
|
||||
addFiles(filesToMerge);
|
||||
setCurrentView("viewer");
|
||||
}
|
||||
})}
|
||||
|
||||
99
frontend/src/components/shared/CardSelector.tsx
Normal file
99
frontend/src/components/shared/CardSelector.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Stack, Card, Text, Flex } from '@mantine/core';
|
||||
import { Tooltip } from './Tooltip';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CardOption<T = string> {
|
||||
value: T;
|
||||
prefixKey: string;
|
||||
nameKey: string;
|
||||
tooltipKey?: string;
|
||||
tooltipContent?: any[];
|
||||
}
|
||||
|
||||
export interface CardSelectorProps<T, K extends CardOption<T>> {
|
||||
options: K[];
|
||||
onSelect: (value: T) => void;
|
||||
disabled?: boolean;
|
||||
getTooltipContent?: (option: K) => any[];
|
||||
}
|
||||
|
||||
const CardSelector = <T, K extends CardOption<T>>({
|
||||
options,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
getTooltipContent
|
||||
}: CardSelectorProps<T, K>) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleOptionClick = (value: T) => {
|
||||
if (!disabled) {
|
||||
onSelect(value);
|
||||
}
|
||||
};
|
||||
|
||||
const getTooltips = (option: K) => {
|
||||
if (getTooltipContent) {
|
||||
return getTooltipContent(option);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{options.map((option) => (
|
||||
<Tooltip
|
||||
key={option.value as string}
|
||||
sidebarTooltip
|
||||
tips={getTooltips(option)}
|
||||
>
|
||||
<Card
|
||||
radius="md"
|
||||
w="100%"
|
||||
h={'2.8rem'}
|
||||
style={{
|
||||
cursor: disabled ? 'default' : 'pointer',
|
||||
backgroundColor: 'var(--mantine-color-gray-2)',
|
||||
borderColor: 'var(--mantine-color-gray-3)',
|
||||
opacity: disabled ? 0.6 : 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-3)';
|
||||
e.currentTarget.style.transform = 'translateY(-1px)';
|
||||
e.currentTarget.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.1)';
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!disabled) {
|
||||
e.currentTarget.style.backgroundColor = 'var(--mantine-color-gray-2)';
|
||||
e.currentTarget.style.transform = 'translateY(0px)';
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
}
|
||||
}}
|
||||
onClick={() => handleOptionClick(option.value)}
|
||||
>
|
||||
<Flex align={'center'} pl="sm" w="100%">
|
||||
<Text size="sm" c="dimmed" ta="center" fw={350}>
|
||||
{t(option.prefixKey, "Prefix")}
|
||||
</Text>
|
||||
<Text
|
||||
fw={600}
|
||||
size="sm"
|
||||
c={undefined}
|
||||
ta="center"
|
||||
style={{ marginLeft: '0.25rem' }}
|
||||
>
|
||||
{t(option.nameKey, "Option Name")}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default CardSelector;
|
||||
@@ -12,7 +12,7 @@ import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
|
||||
interface FileCardProps {
|
||||
file: File;
|
||||
record?: StirlingFileStub;
|
||||
fileStub?: StirlingFileStub;
|
||||
onRemove: () => void;
|
||||
onDoubleClick?: () => void;
|
||||
onView?: () => void;
|
||||
@@ -22,12 +22,11 @@ interface FileCardProps {
|
||||
isSupported?: boolean; // Whether the file format is supported by the current tool
|
||||
}
|
||||
|
||||
const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const FileCard = ({ file, fileStub, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
|
||||
const { t } = useTranslation();
|
||||
// Use record thumbnail if available, otherwise fall back to IndexedDB lookup
|
||||
const fileMetadata = record ? { id: record.id, name: record.name, type: record.type, size: record.size, lastModified: record.lastModified } : null;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
|
||||
const thumb = record?.thumbnailUrl || indexedDBThumb;
|
||||
const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileStub);
|
||||
const thumb = fileStub?.thumbnailUrl || indexedDBThumb;
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -177,7 +176,7 @@ const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSel
|
||||
<Badge color="blue" variant="light" size="sm">
|
||||
{getFileDate(file)}
|
||||
</Badge>
|
||||
{record?.id && (
|
||||
{fileStub?.id && (
|
||||
<Badge
|
||||
color="green"
|
||||
variant="light"
|
||||
|
||||
@@ -139,7 +139,7 @@ const FileGrid = ({
|
||||
<FileCard
|
||||
key={fileId + idx}
|
||||
file={item.file}
|
||||
record={item.record}
|
||||
fileStub={item.record}
|
||||
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
|
||||
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
|
||||
onView={onView && supported ? () => onView(item) : undefined}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Box, Center } from '@mantine/core';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import { FileMetadata } from '../../types/file';
|
||||
import { StirlingFileStub } from '../../types/fileContext';
|
||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||
import DocumentStack from './filePreview/DocumentStack';
|
||||
import HoverOverlay from './filePreview/HoverOverlay';
|
||||
@@ -9,7 +9,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
|
||||
|
||||
export interface FilePreviewProps {
|
||||
// Core file data
|
||||
file: File | FileMetadata | null;
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
|
||||
// Optional features
|
||||
@@ -22,7 +22,7 @@ export interface FilePreviewProps {
|
||||
isAnimating?: boolean;
|
||||
|
||||
// Event handlers
|
||||
onFileClick?: (file: File | FileMetadata | null) => void;
|
||||
onFileClick?: (file: File | StirlingFileStub | null) => void;
|
||||
onPrevious?: () => void;
|
||||
onNext?: () => void;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addMultipleFiles } = useFileHandler();
|
||||
const { addFiles } = useFileHandler();
|
||||
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -15,7 +15,7 @@ const LandingPage = () => {
|
||||
const [isUploadHover, setIsUploadHover] = React.useState(false);
|
||||
|
||||
const handleFileDrop = async (files: File[]) => {
|
||||
await addMultipleFiles(files);
|
||||
await addFiles(files);
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = () => {
|
||||
@@ -29,7 +29,7 @@ const LandingPage = () => {
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
if (files.length > 0) {
|
||||
await addMultipleFiles(files);
|
||||
await addFiles(files);
|
||||
}
|
||||
// Reset the input so the same file can be selected again
|
||||
event.target.value = '';
|
||||
|
||||
153
frontend/src/components/shared/ToolChain.tsx
Normal file
153
frontend/src/components/shared/ToolChain.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* Reusable ToolChain component with smart truncation and tooltip expansion
|
||||
* Used across FileListItem, FileDetails, and FileThumbnail for consistent display
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Tooltip, Badge, Group } from '@mantine/core';
|
||||
import { ToolOperation } from '../../types/file';
|
||||
|
||||
interface ToolChainProps {
|
||||
toolChain: ToolOperation[];
|
||||
maxWidth?: string;
|
||||
displayStyle?: 'text' | 'badges' | 'compact';
|
||||
size?: 'xs' | 'sm' | 'md';
|
||||
color?: string;
|
||||
}
|
||||
|
||||
const ToolChain: React.FC<ToolChainProps> = ({
|
||||
toolChain,
|
||||
maxWidth = '100%',
|
||||
displayStyle = 'text',
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolNames = toolChain.map(tool => tool.toolName);
|
||||
|
||||
// Create full tool chain for tooltip
|
||||
const fullChainDisplay = displayStyle === 'badges' ? (
|
||||
<Group gap="xs" wrap="wrap">
|
||||
{toolChain.map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolName}-${index}`}>
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{tool.toolName}
|
||||
</Badge>
|
||||
{index < toolChain.length - 1 && (
|
||||
<Text size="sm" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm">{toolNames.join(' → ')}</Text>
|
||||
);
|
||||
|
||||
// Create truncated display based on available space
|
||||
const getTruncatedDisplay = () => {
|
||||
if (toolNames.length <= 2) {
|
||||
// Show all tools if 2 or fewer
|
||||
return { text: toolNames.join(' → '), isTruncated: false };
|
||||
} else {
|
||||
// Show first tool ... last tool for longer chains
|
||||
return {
|
||||
text: `${toolNames[0]} → +${toolNames.length-2} → ${toolNames[toolNames.length - 1]}`,
|
||||
isTruncated: true
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const { text: truncatedText, isTruncated } = getTruncatedDisplay();
|
||||
|
||||
// Compact style for very small spaces
|
||||
if (displayStyle === 'compact') {
|
||||
const compactText = toolNames.length === 1 ? toolNames[0] : `${toolNames.length} tools`;
|
||||
const isCompactTruncated = toolNames.length > 1;
|
||||
|
||||
const compactElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isCompactTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{compactText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isCompactTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} multiline withinPortal>
|
||||
{compactElement}
|
||||
</Tooltip>
|
||||
) : compactElement;
|
||||
}
|
||||
|
||||
// Badge style for file details
|
||||
if (displayStyle === 'badges') {
|
||||
const isBadgesTruncated = toolChain.length > 3;
|
||||
|
||||
const badgesElement = (
|
||||
<div style={{ maxWidth: `${maxWidth}`, overflow: 'hidden' }}>
|
||||
<Group gap="2px" wrap="nowrap">
|
||||
{toolChain.slice(0, 3).map((tool, index) => (
|
||||
<React.Fragment key={`${tool.toolName}-${index}`}>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{tool.toolName}
|
||||
</Badge>
|
||||
{index < Math.min(toolChain.length - 1, 2) && (
|
||||
<Text size="xs" c="dimmed">→</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{toolChain.length > 3 && (
|
||||
<>
|
||||
<Text size="xs" c="dimmed">...</Text>
|
||||
<Badge size={size} variant="light" color="blue">
|
||||
{toolChain[toolChain.length - 1].toolName}
|
||||
</Badge>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
|
||||
return isBadgesTruncated ? (
|
||||
<Tooltip label={`${toolNames.join(' → ')}`} withinPortal>
|
||||
{badgesElement}
|
||||
</Tooltip>
|
||||
) : badgesElement;
|
||||
}
|
||||
|
||||
// Text style (default) for file list items
|
||||
const textElement = (
|
||||
<Text
|
||||
size={size}
|
||||
style={{
|
||||
color,
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
maxWidth: `${maxWidth}`,
|
||||
cursor: isTruncated ? 'help' : 'default'
|
||||
}}
|
||||
>
|
||||
{truncatedText}
|
||||
</Text>
|
||||
);
|
||||
|
||||
return isTruncated ? (
|
||||
<Tooltip label={fullChainDisplay} withinPortal>
|
||||
{textElement}
|
||||
</Tooltip>
|
||||
) : textElement;
|
||||
};
|
||||
|
||||
export default ToolChain;
|
||||
@@ -1,10 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Box, Center, Image } from '@mantine/core';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { FileMetadata } from '../../../types/file';
|
||||
import { StirlingFileStub } from '../../../types/fileContext';
|
||||
|
||||
export interface DocumentThumbnailProps {
|
||||
file: File | FileMetadata | null;
|
||||
file: File | StirlingFileStub | null;
|
||||
thumbnail?: string | null;
|
||||
style?: React.CSSProperties;
|
||||
onClick?: () => void;
|
||||
|
||||
@@ -18,7 +18,7 @@ const FileStatusIndicator = ({
|
||||
minFiles = 1,
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { openFilesModal, onFilesSelect } = useFilesModalContext();
|
||||
const { openFilesModal, onFileUpload } = useFilesModalContext();
|
||||
const { files: stirlingFileStubs } = useAllFiles();
|
||||
const { loadRecentFiles } = useFileManager();
|
||||
const [hasRecentFiles, setHasRecentFiles] = useState<boolean | null>(null);
|
||||
@@ -45,7 +45,7 @@ const FileStatusIndicator = ({
|
||||
input.onchange = (event) => {
|
||||
const files = Array.from((event.target as HTMLInputElement).files || []);
|
||||
if (files.length > 0) {
|
||||
onFilesSelect(files);
|
||||
onFileUpload(files);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
|
||||
import { Stack, TextInput, Checkbox, Anchor, Text } from '@mantine/core';
|
||||
import LocalIcon from '../../shared/LocalIcon';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { isSplitMethod, SPLIT_METHODS } from '../../../constants/splitConstants';
|
||||
import { SPLIT_METHODS } from '../../../constants/splitConstants';
|
||||
import { SplitParameters } from '../../../hooks/tools/split/useSplitParameters';
|
||||
|
||||
export interface SplitSettingsProps {
|
||||
@@ -113,32 +114,48 @@ const SplitSettings = ({
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const renderByPageDividerForm = () => (
|
||||
<Stack gap="sm">
|
||||
<Anchor
|
||||
href="https://stirlingpdf.io/files/Auto%20Splitter%20Divider%20(with%20instructions).pdf"
|
||||
target="_blank"
|
||||
size="sm"
|
||||
style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}
|
||||
>
|
||||
<LocalIcon icon="download-rounded" width="2rem" height="2rem" />
|
||||
{t("autoSplitPDF.dividerDownload2", "Download 'Auto Splitter Divider (with instructions).pdf'")}
|
||||
</Anchor>
|
||||
|
||||
<Checkbox
|
||||
label={t("autoSplitPDF.duplexMode", "Duplex Mode (Front and back scanning)")}
|
||||
checked={parameters.duplexMode}
|
||||
onChange={(e) => onParameterChange('duplexMode', e.currentTarget.checked)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
// Don't render anything if no method is selected
|
||||
if (!parameters.method) {
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
<Text c="dimmed" ta="center">
|
||||
{t("split.settings.selectMethodFirst", "Please select a split method first")}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Method Selector */}
|
||||
<Select
|
||||
label={t("split.method.label", "Choose split method")}
|
||||
placeholder={t("split.method.placeholder", "Select how to split the PDF")}
|
||||
value={parameters.method}
|
||||
onChange={(v) => isSplitMethod(v) && onParameterChange('method', v)}
|
||||
disabled={disabled}
|
||||
data={[
|
||||
{ value: SPLIT_METHODS.BY_PAGES, label: t("split.methods.byPages", "Split at Pages Numbers") },
|
||||
{ value: SPLIT_METHODS.BY_SECTIONS, label: t("split.methods.bySections", "Split by Sections") },
|
||||
{ value: SPLIT_METHODS.BY_SIZE, label: t("split.methods.bySize", "Split by Size") },
|
||||
{ value: SPLIT_METHODS.BY_PAGE_COUNT, label: t("split.methods.byPageCount", "Split by Page Count") },
|
||||
{ value: SPLIT_METHODS.BY_DOC_COUNT, label: t("split.methods.byDocCount", "Split by Document Count") },
|
||||
{ value: SPLIT_METHODS.BY_CHAPTERS, label: t("split.methods.byChapters", "Split by Chapters") },
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Parameter Form */}
|
||||
{/* Method-Specific Form */}
|
||||
{parameters.method === SPLIT_METHODS.BY_PAGES && renderByPagesForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_SECTIONS && renderBySectionsForm()}
|
||||
{(parameters.method === SPLIT_METHODS.BY_SIZE ||
|
||||
parameters.method === SPLIT_METHODS.BY_PAGE_COUNT ||
|
||||
parameters.method === SPLIT_METHODS.BY_DOC_COUNT) && renderSplitValueForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_CHAPTERS && renderByChaptersForm()}
|
||||
{parameters.method === SPLIT_METHODS.BY_PAGE_DIVIDER && renderByPageDividerForm()}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
24
frontend/src/components/tooltips/useSplitMethodTips.ts
Normal file
24
frontend/src/components/tooltips/useSplitMethodTips.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useSplitMethodTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("split.methodSelection.tooltip.title", "Choose Your Split Method")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.methodSelection.tooltip.header.title", "Split Method Selection"),
|
||||
description: t("split.methodSelection.tooltip.header.text", "Choose how you want to split your PDF document. Each method is optimized for different use cases and document types."),
|
||||
bullets: [
|
||||
t("split.methodSelection.tooltip.bullet1", "Click on a method card to select it"),
|
||||
t("split.methodSelection.tooltip.bullet2", "Hover over each card to see a quick description"),
|
||||
t("split.methodSelection.tooltip.bullet3", "The settings step will appear after you select a method"),
|
||||
t("split.methodSelection.tooltip.bullet4", "You can change methods at any time before processing")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
134
frontend/src/components/tooltips/useSplitSettingsTips.ts
Normal file
134
frontend/src/components/tooltips/useSplitSettingsTips.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
import { SPLIT_METHODS, type SplitMethod } from '../../constants/splitConstants';
|
||||
|
||||
export const useSplitSettingsTips = (method: SplitMethod | ''): TooltipContent | null => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!method) return null;
|
||||
|
||||
const tooltipMap: Record<SplitMethod, TooltipContent> = {
|
||||
[SPLIT_METHODS.BY_PAGES]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPages.title", "Split at Page Numbers")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPages.title", "Split at Page Numbers"),
|
||||
description: t("split.tooltip.byPages.text", "Extract specific pages or ranges from your PDF. Use commas to separate individual pages and hyphens for ranges."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPages.bullet1", "Single pages: 1,3,5"),
|
||||
t("split.tooltip.byPages.bullet2", "Page ranges: 1-5,10-15"),
|
||||
t("split.tooltip.byPages.bullet3", "Mixed: 1,3-7,12,15-20")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_SECTIONS]: {
|
||||
header: {
|
||||
title: t("split.tooltip.bySections.title", "Split by Grid Sections")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.bySections.title", "Split by Grid Sections"),
|
||||
description: t("split.tooltip.bySections.text", "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySections.bullet1", "Horizontal: Number of rows to create"),
|
||||
t("split.tooltip.bySections.bullet2", "Vertical: Number of columns to create"),
|
||||
t("split.tooltip.bySections.bullet3", "Merge: Combine all sections into one PDF")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_SIZE]: {
|
||||
header: {
|
||||
title: t("split.tooltip.bySize.title", "Split by File Size")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.bySize.title", "Split by File Size"),
|
||||
description: t("split.tooltip.bySize.text", "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySize.bullet1", "Use MB for larger files (e.g., 10MB)"),
|
||||
t("split.tooltip.bySize.bullet2", "Use KB for smaller files (e.g., 500KB)"),
|
||||
t("split.tooltip.bySize.bullet3", "System will split at page boundaries")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_PAGE_COUNT]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPageCount.title", "Split by Page Count")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPageCount.title", "Split by Page Count"),
|
||||
description: t("split.tooltip.byPageCount.text", "Create multiple PDFs with a specific number of pages each. Perfect for creating uniform document chunks."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPageCount.bullet1", "Enter the number of pages per output file"),
|
||||
t("split.tooltip.byPageCount.bullet2", "Last file may have fewer pages if not evenly divisible"),
|
||||
t("split.tooltip.byPageCount.bullet3", "Useful for batch processing workflows")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_DOC_COUNT]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byDocCount.title", "Split by Document Count")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byDocCount.title", "Split by Document Count"),
|
||||
description: t("split.tooltip.byDocCount.text", "Create a specific number of output files by evenly distributing pages across them."),
|
||||
bullets: [
|
||||
t("split.tooltip.byDocCount.bullet1", "Enter the number of output files you want"),
|
||||
t("split.tooltip.byDocCount.bullet2", "Pages are distributed as evenly as possible"),
|
||||
t("split.tooltip.byDocCount.bullet3", "Useful when you need a specific number of files")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_CHAPTERS]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byChapters.title", "Split by Chapters")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byChapters.title", "Split by Chapters"),
|
||||
description: t("split.tooltip.byChapters.text", "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure."),
|
||||
bullets: [
|
||||
t("split.tooltip.byChapters.bullet1", "Bookmark Level: Which level to split on (1=top level)"),
|
||||
t("split.tooltip.byChapters.bullet2", "Include Metadata: Preserve document properties"),
|
||||
t("split.tooltip.byChapters.bullet3", "Allow Duplicates: Handle repeated bookmark names")
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
[SPLIT_METHODS.BY_PAGE_DIVIDER]: {
|
||||
header: {
|
||||
title: t("split.tooltip.byPageDivider.title", "Split by Page Divider")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPageDivider.title", "Split by Page Divider"),
|
||||
description: t("split.tooltip.byPageDivider.text", "Automatically split scanned documents using physical divider sheets with QR codes. Perfect for processing multiple documents scanned together."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPageDivider.bullet1", "Print divider sheets from the download link"),
|
||||
t("split.tooltip.byPageDivider.bullet2", "Insert divider sheets between your documents"),
|
||||
t("split.tooltip.byPageDivider.bullet3", "Scan all documents together as one PDF"),
|
||||
t("split.tooltip.byPageDivider.bullet4", "Upload - divider pages are automatically detected and removed"),
|
||||
t("split.tooltip.byPageDivider.bullet5", "Enable Duplex Mode if scanning both sides of divider sheets")
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
return tooltipMap[method];
|
||||
};
|
||||
@@ -1,59 +0,0 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TooltipContent } from '../../types/tips';
|
||||
|
||||
export const useSplitTips = (): TooltipContent => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return {
|
||||
header: {
|
||||
title: t("split.tooltip.header.title", "Split Methods Overview")
|
||||
},
|
||||
tips: [
|
||||
{
|
||||
title: t("split.tooltip.byPages.title", "Split at Page Numbers"),
|
||||
description: t("split.tooltip.byPages.text", "Extract specific pages or ranges from your PDF. Use commas to separate individual pages and hyphens for ranges."),
|
||||
bullets: [
|
||||
t("split.tooltip.byPages.bullet1", "Single pages: 1,3,5"),
|
||||
t("split.tooltip.byPages.bullet2", "Page ranges: 1-5,10-15"),
|
||||
t("split.tooltip.byPages.bullet3", "Mixed: 1,3-7,12,15-20")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("split.tooltip.bySections.title", "Split by Grid Sections"),
|
||||
description: t("split.tooltip.bySections.text", "Divide each page into a grid of sections. Useful for splitting documents with multiple columns or extracting specific areas."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySections.bullet1", "Horizontal: Number of rows to create"),
|
||||
t("split.tooltip.bySections.bullet2", "Vertical: Number of columns to create"),
|
||||
t("split.tooltip.bySections.bullet3", "Merge: Combine all sections into one PDF")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("split.tooltip.bySize.title", "Split by File Size"),
|
||||
description: t("split.tooltip.bySize.text", "Create multiple PDFs that don't exceed a specified file size. Ideal for file size limitations or email attachments."),
|
||||
bullets: [
|
||||
t("split.tooltip.bySize.bullet1", "Use MB for larger files (e.g., 10MB)"),
|
||||
t("split.tooltip.bySize.bullet2", "Use KB for smaller files (e.g., 500KB)"),
|
||||
t("split.tooltip.bySize.bullet3", "System will split at page boundaries")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("split.tooltip.byCount.title", "Split by Count"),
|
||||
description: t("split.tooltip.byCount.text", "Create multiple PDFs with a specific number of pages or documents each."),
|
||||
bullets: [
|
||||
t("split.tooltip.byCount.bullet1", "Page Count: Fixed number of pages per file"),
|
||||
t("split.tooltip.byCount.bullet2", "Document Count: Fixed number of output files"),
|
||||
t("split.tooltip.byCount.bullet3", "Useful for batch processing workflows")
|
||||
]
|
||||
},
|
||||
{
|
||||
title: t("split.tooltip.byChapters.title", "Split by Chapters"),
|
||||
description: t("split.tooltip.byChapters.text", "Use PDF bookmarks to automatically split at chapter boundaries. Requires PDFs with bookmark structure."),
|
||||
bullets: [
|
||||
t("split.tooltip.byChapters.bullet1", "Bookmark Level: Which level to split on (1=top level)"),
|
||||
t("split.tooltip.byChapters.bullet2", "Include Metadata: Preserve document properties"),
|
||||
t("split.tooltip.byChapters.bullet3", "Allow Duplicates: Handle repeated bookmark names")
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user