diff --git a/frontend/src/core/components/shared/QuickAccessBar.tsx b/frontend/src/core/components/shared/QuickAccessBar.tsx index a472c5ec56..563b2e60a3 100644 --- a/frontend/src/core/components/shared/QuickAccessBar.tsx +++ b/frontend/src/core/components/shared/QuickAccessBar.tsx @@ -8,6 +8,7 @@ import { useFilesModalContext } from '@app/contexts/FilesModalContext'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationState, useNavigationActions } from '@app/contexts/NavigationContext'; import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration'; +import FolderSpecialRoundedIcon from '@mui/icons-material/FolderSpecialRounded'; import { useSidebarNavigation } from '@app/hooks/useSidebarNavigation'; import { handleUnlessSpecialClick } from '@app/utils/clickHandlers'; import { ButtonConfig } from '@app/types/sidebar'; @@ -16,7 +17,6 @@ import { Tooltip } from '@app/components/shared/Tooltip'; import AllToolsNavButton from '@app/components/shared/AllToolsNavButton'; import ActiveToolButton from "@app/components/shared/quickAccessBar/ActiveToolButton"; import AppConfigModal from '@app/components/shared/AppConfigModal'; -import FolderSpecialRoundedIcon from '@mui/icons-material/FolderSpecialRounded'; import { useAppConfig } from '@app/contexts/AppConfigContext'; import { useLicenseAlert } from "@app/hooks/useLicenseAlert"; import { requestStartTour } from '@app/constants/events'; @@ -164,18 +164,7 @@ const QuickAccessBar = forwardRef((_, ref) => { return availability?.available !== false; }), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]); - const isWatchFoldersActive = workbench === SMART_FOLDER_WORKBENCH_ID; - const middleButtons: ButtonConfig[] = [ - { - id: 'files', - name: t("quickAccess.files", "Files"), - icon: , - isRound: true, - size: 'md', - type: 'modal', - onClick: handleFilesButtonClick - }, { id: 'watchFolders', name: t("quickAccess.watchFolders", "Watch Folders"), @@ -189,6 +178,15 @@ const QuickAccessBar = forwardRef((_, ref) => { navigationActions.setWorkbench(SMART_FOLDER_WORKBENCH_ID); } }, + { + id: 'files', + name: t("quickAccess.files", "Files"), + icon: , + isRound: true, + size: 'md', + type: 'modal', + onClick: handleFilesButtonClick + }, ]; //TODO: Activity //{ diff --git a/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx b/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx index e3c6d58f26..88c3f20962 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx @@ -2,13 +2,13 @@ import { useState, useCallback, useEffect } from 'react'; import { Box, Text, Stack, Group, ActionIcon, Button, Loader, ScrollArea } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import AddIcon from '@mui/icons-material/Add'; -import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import EditIcon from '@mui/icons-material/Edit'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; import ChevronRightIcon from '@mui/icons-material/ChevronRight'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; import FolderPlusIcon from '@mui/icons-material/CreateNewFolder'; +import PauseCircleOutlineIcon from '@mui/icons-material/PauseCircleOutline'; +import PlayCircleOutlineIcon from '@mui/icons-material/PlayCircleOutline'; import { useSmartFolders } from '@app/hooks/useSmartFolders'; import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses'; import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders'; @@ -51,6 +51,7 @@ interface FolderCardProps { onDelete: (folder: SmartFolder) => void; onOpen: (folderId: string) => void; onDropFiles: (folder: SmartFolder, files: File[]) => void; + onTogglePause: (folder: SmartFolder) => void; } function FolderCard({ @@ -61,33 +62,40 @@ function FolderCard({ onDelete, onOpen, onDropFiles, + onTogglePause, }: FolderCardProps) { const { t } = useTranslation(); const [automation, setAutomation] = useState(null); const [fileCount, setFileCount] = useState(0); + const [lastAdded, setLastAdded] = useState(null); const [isDragOver, setIsDragOver] = useState(false); const [isHovered, setIsHovered] = useState(false); useEffect(() => { automationStorage.getAutomation(folder.automationId).then(setAutomation); - const loadCount = () => + const loadData = () => folderStorage.getFolderData(folder.id).then((record) => { - setFileCount(record ? Object.keys(record.files).length : 0); + if (!record) { setFileCount(0); setLastAdded(null); return; } + const files = Object.values(record.files); + setFileCount(files.length); + const dates = files.map(f => new Date(f.addedAt)).filter(d => !isNaN(d.getTime())); + setLastAdded(dates.length ? new Date(Math.max(...dates.map(d => d.getTime()))) : null); }); - loadCount(); + loadData(); const unsub = folderStorage.onFolderChange((changedId) => { - if (changedId === folder.id) loadCount(); + if (changedId === folder.id) loadData(); }); return unsub; }, [folder.id, folder.automationId]); const FolderIcon = iconMap[folder.icon as keyof typeof iconMap] ?? iconMap.FolderIcon; - const isActive = isProcessing || status === 'processing'; - const isDone = status === 'done' && !isActive; + const isPaused = folder.isPaused ?? false; + const isActive = !isPaused && (isProcessing || status === 'processing'); + const isDone = !isPaused && status === 'done' && !isActive; - const statusDotColor = isActive ? '#3b82f6' : isDone ? '#22c55e' : '#6b7280'; + const statusDotColor = isPaused ? 'var(--mantine-color-dimmed)' : isActive ? '#3b82f6' : isDone ? '#22c55e' : '#6b7280'; const statusDotPulse = isActive; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }; @@ -102,15 +110,13 @@ function FolderCard({ const ops = automation?.operations ?? []; - const cardBorderColor = isDragOver + const cardBorderColor = isDragOver || isHovered ? folder.accentColor - : isHovered - ? folder.accentColor - : 'var(--border-subtle)'; + : 'var(--mantine-color-default-border)'; const cardBoxShadow = isDragOver || isHovered - ? `0 0.25rem 0.75rem ${folder.accentColor}15` - : 'none'; + ? `0 0.25rem 0.75rem ${folder.accentColor}25` + : '0 0.0625rem 0.25rem rgba(0,0,0,0.08)'; return ( - {isActive + {isPaused + ? t('smartFolders.status.paused', 'Paused') + : isActive ? t('smartFolders.status.processing', 'Processing') - : isDone - ? t('smartFolders.status.done', 'Done') - : t('smartFolders.status.idle', 'Idle')} + : t('smartFolders.status.active', 'Active')} {/* Right: file count + hover actions */} - e.stopPropagation()}> - - - {fileCount} - - - {fileCount === 1 + e.stopPropagation()}> + + + {fileCount} {fileCount === 1 ? t('smartFolders.home.file', 'file') : t('smartFolders.home.files', 'files')} + {lastAdded && ( + + {(() => { + const now = new Date(); + const isToday = lastAdded.toDateString() === now.toDateString(); + if (isToday) { + const hrs = Math.floor((now.getTime() - lastAdded.getTime()) / 3600000); + return hrs < 1 ? 'just now' : `${hrs}hr${hrs === 1 ? '' : 's'} ago`; + } + const days = Math.floor((now.getTime() - lastAdded.getTime()) / 86400000); + return days === 1 ? '1 day ago' : `${days} days ago`; + })()} + + )} onTogglePause(folder)} + aria-label={isPaused ? t('smartFolders.home.resume', 'Resume') : t('smartFolders.home.pause', 'Pause')} + title={isPaused ? t('smartFolders.home.resume', 'Resume') : t('smartFolders.home.pause', 'Pause')} + > + {isPaused + ? + : } + + onEdit(folder)} aria-label={t('smartFolders.home.editFolder', 'Edit folder')} > - + onDelete(folder)} aria-label={t('smartFolders.home.deleteFolder', 'Delete folder')} > - - - onOpen(folder.id)} - aria-label={t('smartFolders.home.openFolder', 'Open folder')} - > - + @@ -301,28 +318,11 @@ function FolderCard({ )} - {/* Drop hint / description row */} - - - - {isDragOver - ? t('smartFolders.home.dropHere', 'Drop to process') - : folder.description || t('smartFolders.home.dragHint', 'Drop PDFs to process')} + {isDragOver && ( + + {t('smartFolders.home.dropHere', 'Drop to process')} - + )} @@ -461,7 +461,7 @@ function EmptyState({ onCreate }: { onCreate: () => void }) { export function SmartFolderHomePage() { const { t } = useTranslation(); - const { folders, loading, deleteFolder, refreshFolders } = useSmartFolders(); + const { folders, loading, deleteFolder, updateFolder, refreshFolders } = useSmartFolders(); const statuses = useFolderRunStatuses(folders); const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow(); const { actions } = useNavigationActions(); @@ -480,6 +480,11 @@ export function SmartFolderHomePage() { [setCustomWorkbenchViewData, actions] ); + const handleTogglePause = useCallback(async (folder: SmartFolder) => { + await updateFolder({ ...folder, isPaused: !folder.isPaused }); + refreshFolders(); + }, [updateFolder, refreshFolders]); + const handleEdit = useCallback(async (folder: SmartFolder) => { setEditFolder(folder); const automation = await automationStorage.getAutomation(folder.automationId); @@ -610,6 +615,7 @@ export function SmartFolderHomePage() { onDelete={setDeleteTarget} onOpen={navigateToFolder} onDropFiles={processFiles} + onTogglePause={handleTogglePause} /> ); })} diff --git a/frontend/src/core/components/smartFolders/SmartFolderSidebarPanel.tsx b/frontend/src/core/components/smartFolders/SmartFolderSidebarPanel.tsx index fbb4fc9020..dba92c1b05 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderSidebarPanel.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderSidebarPanel.tsx @@ -46,7 +46,7 @@ export function SmartFolderSidebarPanel() { - {t('smartFolders.sidebarFiles', 'Your Files')} + {t('smartFolders.sidebarFiles', 'My Files')} f.id === folderId); @@ -80,10 +82,10 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps const [isDragOver, setIsDragOver] = useState(false); const [stepProgresses, setStepProgresses] = useState([]); - const [filterTab, setFilterTab] = useState('processed'); const [outputFiles, setOutputFiles] = useState([]); const [inputFiles, setInputFiles] = useState([]); const [automation, setAutomation] = useState(null); + const [storageModal, setStorageModal] = useState<'input' | 'output' | 'failed' | null>(null); const processingRef = useRef>(new Set()); const handledPendingRef = useRef(null); @@ -203,6 +205,12 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps [handleFiles] ); + const handleView = useCallback(async (blob: Blob, name: string) => { + const file = new File([blob], name, { type: 'application/pdf' }); + await fileActions.addFiles([file]); + actions.setWorkbench('viewer'); + }, [fileActions, actions]); + const handleDownload = useCallback(async (blob: Blob, name: string) => { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -231,15 +239,27 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps ); } + const failedFileIds = fileIds.filter(id => folderRecord?.files[id]?.status === 'error'); const isProcessingAny = processingFileIds.length > 0; const ops = automation?.operations ?? []; - // Which files to show in the right panel - const tabFileIds = filterTab === 'processed' - ? processedFileIds - : filterTab === 'processing' - ? processingFileIds - : pendingFileIds; + const hasCompressStep = ops.some(op => op.operation === 'compress'); + const totalInputBytes = inputFiles.reduce((sum, f) => sum + f.blob.size, 0); + const totalOutputBytes = outputFiles.reduce((sum, f) => sum + f.blob.size, 0); + const dataSavedBytes = totalInputBytes - totalOutputBytes; + + function formatBytes(bytes: number): string { + const abs = Math.abs(bytes); + if (abs >= 1073741824) return `${(bytes / 1073741824).toFixed(1)} GB`; + if (abs >= 1048576) return `${(bytes / 1048576).toFixed(1)} MB`; + if (abs >= 1024) return `${(bytes / 1024).toFixed(0)} KB`; + return `${bytes} B`; + } + + const daysRunning = folder.createdAt + ? Math.floor((Date.now() - new Date(folder.createdAt).getTime()) / 86400000) + : null; + return ( @@ -284,6 +304,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps height: '0.75rem', borderRadius: '50%', backgroundColor: 'var(--bg-toolbar)', + textAlign: 'center', display: 'flex', alignItems: 'center', justifyContent: 'center', @@ -325,24 +346,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps - {/* Stats + Add files */} + {/* Add files */} - - - {processedFileIds.length} - - - {t('smartFolders.workbench.processed', 'Processed')} - - - - - {fileIds.length} - - - {t('smartFolders.workbench.total', 'Total')} - - - )} - - - ); - })} - - ) - ) : ( - tabFileIds.length === 0 ? ( - - {t('smartFolders.workbench.noFiles', 'No files')} - - ) : ( - - {tabFileIds.map((fileId) => { - const meta = folderRecord?.files[fileId]; - const status = meta?.status ?? 'pending'; - return ( - - {status === 'processing' && } - {status === 'pending' && ( - - )} - - {status === 'processing' - ? t('smartFolders.workbench.fileProcessing', 'Processing…') - : t('smartFolders.workbench.filePending', 'Queued')} - - - ); - })} - - ) - )} - - - - + + {/* Storage file modal */} + setStorageModal(null)} + title={ + + {storageModal === 'input' + ? t('smartFolders.workbench.inputFiles', 'Input files') + : storageModal === 'output' + ? t('smartFolders.workbench.outputFiles', 'Output files') + : t('smartFolders.workbench.failedFiles', 'Failed files')} + + } + size="md" + > + {storageModal === 'input' && ( + inputFiles.length === 0 ? ( + + {t('smartFolders.workbench.noInputFiles', 'No input files stored yet')} + + ) : ( + + {inputFiles.map((file) => ( + + + {file.name} + handleDownload(file.blob, file.name)} + aria-label={t('smartFolders.workbench.download', 'Download')} + > + + + + ))} + + ) + )} + {storageModal === 'output' && ( + outputFiles.length === 0 ? ( + + {t('smartFolders.workbench.noOutputFiles', 'No output files stored yet')} + + ) : ( + + {outputFiles.map((file) => ( + + + {file.name} + handleDownload(file.blob, file.name)} + aria-label={t('smartFolders.workbench.download', 'Download')} + > + + + + ))} + + ) + )} + {storageModal === 'failed' && ( + failedFileIds.length === 0 ? ( + + {t('smartFolders.workbench.noFailedFiles', 'No failed files')} + + ) : ( + + {failedFileIds.map((fileId) => { + const meta = folderRecord?.files[fileId]; + const inputFile = inputFiles.find(f => f.fileId === fileId); + const filename = meta?.name ?? inputFile?.name ?? fileId; + return ( + + + + {filename} + {inputFile && ( + handleDownload(inputFile.blob, inputFile.name)}> + + + )} + {inputFile && ( + { + await updateFileMetadata(fileId, { status: 'pending', errorMessage: undefined }); + const file = new File([inputFile.blob], inputFile.name, { type: 'application/pdf' }); + runAutomation(file, fileId); + setStorageModal(null); + }} + title="Retry" + > + + + )} + + {meta?.errorMessage && ( + {meta.errorMessage} + )} + + ); + })} + + ) + )} + ); } diff --git a/frontend/src/core/types/smartFolders.ts b/frontend/src/core/types/smartFolders.ts index 00261da9f5..716e5e6c4e 100644 --- a/frontend/src/core/types/smartFolders.ts +++ b/frontend/src/core/types/smartFolders.ts @@ -13,6 +13,7 @@ export interface SmartFolder { updatedAt: string; order?: number; isDefault?: boolean; + isPaused?: boolean; } export interface FolderFileMetadata {