Feature/v2/filehistory (#4370)

File History

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-09-16 15:08:11 +01:00
committed by GitHub
parent 8e8b417f5e
commit 190178a471
61 changed files with 2279 additions and 1245 deletions

View File

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

View File

@@ -78,22 +78,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);
@@ -404,13 +388,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}
@@ -421,7 +402,7 @@ const FileEditor = ({
onSetStatus={setStatus}
onReorderFiles={handleReorderFiles}
toolMode={toolMode}
isSupported={isFileSupported(fileItem.name)}
isSupported={isFileSupported(record.name)}
/>
);
})}

View File

@@ -8,22 +8,17 @@ 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 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[];
@@ -64,6 +59,8 @@ const FileEditorThumbnail = ({
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const pageCount = file.processedFile?.totalPages || 0;
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === 'function') {
@@ -109,22 +106,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) => {
@@ -350,7 +346,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 +357,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 +396,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>
);

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 = '';

View 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;

View File

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

View File

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

View File

@@ -372,11 +372,12 @@ const Viewer = ({
else if (effectiveFile.url?.startsWith('indexeddb:')) {
const fileId = effectiveFile.url.replace('indexeddb:', '') as FileId /* FIX ME: Not sure this is right - at least probably not the right place for this logic */;
// Get data directly from IndexedDB
const arrayBuffer = await fileStorage.getFileData(fileId);
if (!arrayBuffer) {
// Get file directly from IndexedDB
const file = await fileStorage.getStirlingFile(fileId);
if (!file) {
throw new Error('File not found in IndexedDB - may have been purged by browser');
}
const arrayBuffer = await file.arrayBuffer();
// Store reference for cleanup
currentArrayBufferRef.current = arrayBuffer;