From 1aff53e405e7b5254a8ec90ac32107fddbb5ce61 Mon Sep 17 00:00:00 2001 From: Reece Date: Mon, 9 Mar 2026 16:26:49 +0000 Subject: [PATCH] Ui overhaul step 1 --- .../smartFolders/SmartFolderHomePage.tsx | 673 ++++++++++---- .../smartFolders/SmartFolderWorkbenchView.tsx | 879 ++++++++++++------ frontend/src/core/services/folderStorage.ts | 62 +- frontend/src/core/types/smartFolders.ts | 1 + 4 files changed, 1165 insertions(+), 450 deletions(-) diff --git a/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx b/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx index 77c253c77e..e3c6d58f26 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderHomePage.tsx @@ -1,10 +1,14 @@ import { useState, useCallback, useEffect } from 'react'; -import { Box, Text, Stack, Group, ActionIcon, Badge, Button, Loader, ScrollArea } from '@mantine/core'; +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 { useSmartFolders } from '@app/hooks/useSmartFolders'; import { useFolderRunStatuses } from '@app/hooks/useFolderRunStatuses'; import { SmartFolder, SmartFolderRunEntry } from '@app/types/smartFolders'; @@ -18,13 +22,25 @@ import { SmartFolderManagementModal } from '@app/components/smartFolders/SmartFo import { DeleteFolderConfirmModal } from '@app/components/smartFolders/DeleteFolderConfirmModal'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; import { useNavigationActions } from '@app/contexts/NavigationContext'; -import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration'; +import { + SMART_FOLDER_VIEW_ID, + SMART_FOLDER_WORKBENCH_ID, +} from '@app/components/smartFolders/SmartFoldersRegistration'; + +const KEYFRAMES = ` + @keyframes wf-pulse { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.35; transform: scale(0.7); } + } +`; -// Humanise an operation key like "compress-pdf" → "Compress PDF" function humaniseOp(op: string): string { return op + .replace(/-pdf$|-pages$|-documents?$/i, '') .replace(/[-_]/g, ' ') - .replace(/\b\w/g, (c) => c.toUpperCase()); + .replace(/\bocr\b/gi, 'OCR') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); } interface FolderCardProps { @@ -32,15 +48,25 @@ interface FolderCardProps { status: 'idle' | 'processing' | 'done'; isProcessing: boolean; onEdit: (folder: SmartFolder) => void; + onDelete: (folder: SmartFolder) => void; onOpen: (folderId: string) => void; onDropFiles: (folder: SmartFolder, files: File[]) => void; } -function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles }: FolderCardProps) { +function FolderCard({ + folder, + status, + isProcessing, + onEdit, + onDelete, + onOpen, + onDropFiles, +}: FolderCardProps) { const { t } = useTranslation(); const [automation, setAutomation] = useState(null); const [fileCount, setFileCount] = useState(0); const [isDragOver, setIsDragOver] = useState(false); + const [isHovered, setIsHovered] = useState(false); useEffect(() => { automationStorage.getAutomation(folder.automationId).then(setAutomation); @@ -58,11 +84,13 @@ function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles }, [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 handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; + const statusDotColor = isActive ? '#3b82f6' : isDone ? '#22c55e' : '#6b7280'; + const statusDotPulse = isActive; + + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(true); }; const handleDragLeave = (e: React.DragEvent) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }; @@ -72,135 +100,361 @@ function FolderCard({ folder, status, isProcessing, onEdit, onOpen, onDropFiles onDropFiles(folder, Array.from(e.dataTransfer.files)); }; + const ops = automation?.operations ?? []; + + const cardBorderColor = isDragOver + ? folder.accentColor + : isHovered + ? folder.accentColor + : 'var(--border-subtle)'; + + const cardBoxShadow = isDragOver || isHovered + ? `0 0.25rem 0.75rem ${folder.accentColor}15` + : 'none'; + return ( setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onDrop={handleDrop} + onClick={() => onOpen(folder.id)} > - - {/* Icon circle */} - - - + + - {/* Main content */} - - {/* Top row: name + badges + actions */} - - - - {folder.name} - - {fileCount > 0 && ( - - {fileCount} {fileCount === 1 - ? t('smartFolders.home.file', 'file') - : t('smartFolders.home.files', 'files')} - - )} - {isProcessing && ( - - {t('smartFolders.status.processing', 'Processing')} - - )} - {status === 'done' && !isProcessing && ( - - {t('smartFolders.status.done', 'Done')} - - )} - + {/* Icon with status dot */} + + + + + {/* Status dot overlay */} + + + + - - { e.stopPropagation(); onEdit(folder); }} - aria-label={t('smartFolders.home.editFolder', 'Edit folder')} - > - - - { e.stopPropagation(); onOpen(folder.id); }} - aria-label={t('smartFolders.home.openFolder', 'Open folder')} - > - - - - - - {/* Description */} - {folder.description && ( - - {folder.description} - - )} - - {/* Operation pills */} - {automation && automation.operations.length > 0 && ( - - {automation.operations.map((op, i) => ( - + {/* Name + status pill + actions */} + + + - {humaniseOp(op.operation)} - - ))} - - )} - - + {folder.name} + + + {isActive + ? t('smartFolders.status.processing', 'Processing') + : isDone + ? t('smartFolders.status.done', 'Done') + : t('smartFolders.status.idle', 'Idle')} + + - {/* Drop overlay */} - {isDragOver && ( - - - - - {t('smartFolders.home.dropHere', 'Drop to process')} - + {/* Right: file count + hover actions */} + e.stopPropagation()}> + + + {fileCount} + + + {fileCount === 1 + ? t('smartFolders.home.file', 'file') + : t('smartFolders.home.files', 'files')} + + + + + 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')} + > + + + + + + + {/* Pipeline chips */} + {ops.length > 0 && ( + + {ops.map((op, i) => ( + + {i > 0 && ( + + )} + + {humaniseOp(op.operation)} + + + ))} + + )} + {ops.length === 0 && ( + + {t('smartFolders.home.noSteps', 'No automation steps configured')} + + )} + + {/* Drop hint / description row */} + + + + {isDragOver + ? t('smartFolders.home.dropHere', 'Drop to process') + : folder.description || t('smartFolders.home.dragHint', 'Drop PDFs to process')} + + + + + + + ); +} + +function HowItWorks() { + const { t } = useTranslation(); + + const steps = [ + { + n: '1', + title: t('smartFolders.howItWorks.step1Title', 'Drop files'), + desc: t('smartFolders.howItWorks.step1Desc', 'Drag PDFs onto any Watch Folder card — or send them from your file list'), + }, + { + n: '2', + title: t('smartFolders.howItWorks.step2Title', 'Pipeline runs'), + desc: t('smartFolders.howItWorks.step2Desc', 'Your configured tools process each file automatically'), + }, + { + n: '3', + title: t('smartFolders.howItWorks.step3Title', 'Output ready'), + desc: t('smartFolders.howItWorks.step3Desc', 'Download processed files from inside the folder'), + }, + ]; + + return ( + + + + + {t('smartFolders.howItWorks.title', 'How Watch Folders work')} + + + + {steps.map((step) => ( + + + {step.n} + + + + {step.title} + + + {step.desc} + + - - )} + ))} + + + ); +} + +function EmptyState({ onCreate }: { onCreate: () => void }) { + const { t } = useTranslation(); + + return ( + + + + + + + + {t('smartFolders.home.emptyTitle', 'Automate your PDF workflows')} + + + {t( + 'smartFolders.home.emptyDesc', + "Set up a Watch Folder once. Drop PDFs in and they're automatically compressed, OCR'd, split, merged — whatever your pipeline does." + )} + + + + + + ); } @@ -218,10 +472,13 @@ export function SmartFolderHomePage() { const [processingFolderIds, setProcessingFolderIds] = useState>(new Set()); const [deleteTarget, setDeleteTarget] = useState(null); - const navigateToFolder = useCallback((folderId: string) => { - setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId }); - actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID); - }, [setCustomWorkbenchViewData, actions]); + const navigateToFolder = useCallback( + (folderId: string) => { + setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId }); + actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID); + }, + [setCustomWorkbenchViewData, actions] + ); const handleEdit = useCallback(async (folder: SmartFolder) => { setEditFolder(folder); @@ -236,44 +493,49 @@ export function SmartFolderHomePage() { setEditAutomation(null); }; - const processFiles = useCallback(async (folder: SmartFolder, files: File[]) => { - const pdfs = files.filter(f => f.name.toLowerCase().endsWith('.pdf')); - if (pdfs.length === 0) return; + const processFiles = useCallback( + async (folder: SmartFolder, files: File[]) => { + const pdfs = files.filter((f) => f.name.toLowerCase().endsWith('.pdf')); + if (pdfs.length === 0) return; - setProcessingFolderIds(prev => new Set([...prev, folder.id])); - try { - const automation: AutomationConfig | null = await automationStorage.getAutomation(folder.automationId); - if (!automation) return; + setProcessingFolderIds((prev) => new Set([...prev, folder.id])); + try { + const automation: AutomationConfig | null = await automationStorage.getAutomation( + folder.automationId + ); + if (!automation) return; - for (const file of pdfs) { - const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - await folderStorage.addFileToFolder(folder.id, inputFileId, { status: 'processing' }); - try { - const resultFiles = await executeAutomationSequence(automation, [file], toolRegistry); - const existingRuns = await folderRunStateStorage.getFolderRunState(folder.id); - const newRuns: SmartFolderRunEntry[] = [...existingRuns]; - for (const resultFile of resultFiles) { - const outputId = `output-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - await folderStorage.storeOutputFile(folder.id, outputId, resultFile, resultFile.name); - newRuns.push({ inputFileId, displayFileId: outputId, status: 'processed' }); + for (const file of pdfs) { + const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + await folderStorage.addFileToFolder(folder.id, inputFileId, { status: 'processing' }); + try { + const resultFiles = await executeAutomationSequence(automation, [file], toolRegistry); + const existingRuns = await folderRunStateStorage.getFolderRunState(folder.id); + const newRuns: SmartFolderRunEntry[] = [...existingRuns]; + for (const resultFile of resultFiles) { + const outputId = `output-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + await folderStorage.storeOutputFile(folder.id, outputId, resultFile, resultFile.name); + newRuns.push({ inputFileId, displayFileId: outputId, status: 'processed' }); + } + await folderStorage.updateFileMetadata(folder.id, inputFileId, { + status: 'processed', + processedAt: new Date(), + }); + await folderRunStateStorage.setFolderRunState(folder.id, newRuns); + } catch { + await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'error' }); } - await folderStorage.updateFileMetadata(folder.id, inputFileId, { - status: 'processed', - processedAt: new Date(), - }); - await folderRunStateStorage.setFolderRunState(folder.id, newRuns); - } catch { - await folderStorage.updateFileMetadata(folder.id, inputFileId, { status: 'error' }); } + } finally { + setProcessingFolderIds((prev) => { + const next = new Set(prev); + next.delete(folder.id); + return next; + }); } - } finally { - setProcessingFolderIds(prev => { - const next = new Set(prev); - next.delete(folder.id); - return next; - }); - } - }, [toolRegistry]); + }, + [toolRegistry] + ); const handleDeleteConfirm = async () => { if (!deleteTarget) return; @@ -283,22 +545,32 @@ export function SmartFolderHomePage() { return ( + + {/* Header */} - {t('smartFolders.home.title', 'Watch Folders')} + + + {t('smartFolders.home.title', 'Watch Folders')} + + + {t( + 'smartFolders.home.subtitle', + 'Folders that automatically process PDFs with your configured pipeline' + )} + + - + setCreateModalOpen(true)} /> ) : ( - + {folders.map((folder) => { const status = statuses[folder.id] ?? 'idle'; - const isProcessing = processingFolderIds.has(folder.id) || status === 'processing'; + const isProcessing = + processingFolderIds.has(folder.id) || status === 'processing'; return ( ); })} + + {/* Add another prompt */} + { (e.currentTarget as HTMLElement).style.borderColor = 'var(--mantine-color-blue-filled)'; }} + onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.borderColor = 'var(--border-subtle)'; }} + onClick={() => setCreateModalOpen(true)} + > + + + + + {t('smartFolders.home.addAnother', 'Add another Watch Folder')} + + + {t('smartFolders.home.addAnotherDesc', 'Automatically process files with a new pipeline')} + + + + )} diff --git a/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx b/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx index 192d063acb..5424c5bbf2 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx @@ -5,7 +5,6 @@ import { Stack, Group, Button, - Badge, ScrollArea, Loader, Tabs, @@ -13,21 +12,29 @@ import { } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import DownloadIcon from '@mui/icons-material/Download'; -import UploadFileIcon from '@mui/icons-material/UploadFile'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import ChevronRightIcon from '@mui/icons-material/ChevronRight'; +import FolderOpenIcon from '@mui/icons-material/FolderOpen'; +import TaskAltIcon from '@mui/icons-material/TaskAlt'; +import HistoryIcon from '@mui/icons-material/History'; import { useSmartFolders } from '@app/hooks/useSmartFolders'; import { useFolderData } from '@app/hooks/useFolderData'; import { useFolderRunState } from '@app/hooks/useFolderRunState'; import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext'; -import { SMART_FOLDER_VIEW_ID } from '@app/components/smartFolders/SmartFoldersRegistration'; +import { SMART_FOLDER_VIEW_ID, SMART_FOLDER_WORKBENCH_ID } from '@app/components/smartFolders/SmartFoldersRegistration'; import { automationStorage } from '@app/services/automationStorage'; import { folderStorage } from '@app/services/folderStorage'; import { executeAutomationSequence } from '@app/utils/automationExecutor'; -import { SmartFolderRunEntry } from '@app/types/smartFolders'; +import { SmartFolderRunEntry, SmartFolder } from '@app/types/smartFolders'; import { AutomationConfig } from '@app/types/automation'; import { iconMap } from '@app/components/tools/automate/iconMap'; import { fileStorage } from '@app/services/fileStorage'; import { FileId } from '@app/types/fileContext'; +import { InputFileRecord, OutputFileRecord } from '@app/services/folderStorage'; import { SmartFolderHomePage } from '@app/components/smartFolders/SmartFolderHomePage'; +import { useNavigationActions } from '@app/contexts/NavigationContext'; interface SmartFolderWorkbenchViewProps { data: { folderId: string | null; pendingFileId?: string }; @@ -40,12 +47,22 @@ interface StepProgress { error?: string; } -type FilterTab = 'all' | 'pending' | 'processing' | 'processed'; +type FilterTab = 'processed' | 'processing' | 'pending'; + +function humaniseOp(op: string): string { + return op + .replace(/-pdf$|-pages$|-documents?$/i, '') + .replace(/[-_]/g, ' ') + .replace(/\bocr\b/gi, 'OCR') + .replace(/\b\w/g, (c) => c.toUpperCase()) + .trim(); +} export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps) { const { folderId } = data; const { t } = useTranslation(); const { toolRegistry, setCustomWorkbenchViewData } = useToolWorkflow(); + const { actions } = useNavigationActions(); const { folders } = useSmartFolders(); const folder = folders.find(f => f.id === folderId); @@ -63,18 +80,24 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps const [isDragOver, setIsDragOver] = useState(false); const [stepProgresses, setStepProgresses] = useState([]); - const [filterTab, setFilterTab] = useState('all'); - const [outputFiles, setOutputFiles] = useState<{ fileId: string; name: string; blob: Blob }[]>([]); + const [filterTab, setFilterTab] = useState('processed'); + const [outputFiles, setOutputFiles] = useState([]); + const [inputFiles, setInputFiles] = useState([]); + const [automation, setAutomation] = useState(null); const processingRef = useRef>(new Set()); const handledPendingRef = useRef(null); - // Load output files useEffect(() => { - folderStorage.getOutputFilesByFolder(folderId).then(records => { - setOutputFiles(records.map(r => ({ fileId: r.fileId, name: r.name, blob: r.blob }))); - }); + folderStorage.getOutputFilesByFolder(folderId).then(setOutputFiles); + folderStorage.getInputFilesByFolder(folderId).then(setInputFiles); }, [folderId, recentRuns]); + useEffect(() => { + if (folder?.automationId) { + automationStorage.getAutomation(folder.automationId).then(setAutomation); + } + }, [folder?.automationId]); + const runAutomation = useCallback( async (inputFile: File, inputFileId: string) => { if (processingRef.current.has(inputFileId)) return; @@ -82,48 +105,41 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps try { if (!folder) return; - const automation: AutomationConfig | null = await automationStorage.getAutomation(folder.automationId); - if (!automation) { + const auto: AutomationConfig | null = await automationStorage.getAutomation(folder.automationId); + if (!auto) { await updateFileMetadata(inputFileId, { status: 'error', errorMessage: 'Automation not found' }); return; } await updateFileMetadata(inputFileId, { status: 'processing' }); - const totalSteps = automation.operations.length; setStepProgresses(prev => [ ...prev, - { stepIndex: 0, operationName: automation.operations[0]?.operation ?? '', status: 'running' }, + { stepIndex: 0, operationName: auto.operations[0]?.operation ?? '', status: 'running' }, ]); const resultFiles = await executeAutomationSequence( - automation, + auto, [inputFile], toolRegistry, (stepIndex, operationName) => { setStepProgresses(prev => { - const updated = [...prev.filter(p => !(p.stepIndex === stepIndex && p.operationName === operationName))]; - updated.push({ stepIndex, operationName, status: 'running' }); - return updated; + const updated = prev.filter(p => !(p.stepIndex === stepIndex && p.operationName === operationName)); + return [...updated, { stepIndex, operationName, status: 'running' }]; }); }, - (stepIndex, resultFiles) => { + (stepIndex) => { setStepProgresses(prev => - prev.map(p => - p.stepIndex === stepIndex ? { ...p, status: 'completed' } : p - ) + prev.map(p => p.stepIndex === stepIndex ? { ...p, status: 'completed' } : p) ); }, (stepIndex, error) => { setStepProgresses(prev => - prev.map(p => - p.stepIndex === stepIndex ? { ...p, status: 'error', error } : p - ) + prev.map(p => p.stepIndex === stepIndex ? { ...p, status: 'error', error } : p) ); } ); - // Store output files const newRuns: SmartFolderRunEntry[] = [...recentRuns]; for (const resultFile of resultFiles) { const outputId = `output-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; @@ -152,19 +168,18 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps for (const file of fileArray) { if (!file.name.toLowerCase().endsWith('.pdf')) continue; const inputFileId = `input-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - await addFile(inputFileId, { status: 'pending', inputFileId }); + await addFile(inputFileId, { status: 'pending', inputFileId, name: file.name }); + await folderStorage.storeInputFile(folderId, inputFileId, file, file.name); runAutomation(file, inputFileId); } }, - [addFile, runAutomation] + [addFile, folderId, runAutomation] ); - // Auto-process a file passed via workbench data (e.g. from sidebar or "Add to Smart Folder") useEffect(() => { const { pendingFileId } = data; if (!pendingFileId || handledPendingRef.current === pendingFileId) return; handledPendingRef.current = pendingFileId; - // Clear pendingFileId from workbench data so re-mounting doesn't replay it setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId }); fileStorage.getStirlingFile(pendingFileId as FileId).then((stirlingFile) => { if (stirlingFile) handleFiles([stirlingFile]); @@ -175,27 +190,21 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); - if (e.dataTransfer.files.length > 0) { - handleFiles(e.dataTransfer.files); - } + if (e.dataTransfer.files.length > 0) handleFiles(e.dataTransfer.files); }, [handleFiles] ); const handleFileInput = useCallback( (e: React.ChangeEvent) => { - if (e.target.files && e.target.files.length > 0) { - handleFiles(e.target.files); - } + if (e.target.files && e.target.files.length > 0) handleFiles(e.target.files); e.target.value = ''; }, [handleFiles] ); - const handleDownload = useCallback(async (fileId: string, name: string) => { - const record = await folderStorage.getOutputFile(fileId); - if (!record) return; - const url = URL.createObjectURL(record.blob); + const handleDownload = useCallback(async (blob: Blob, name: string) => { + const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; @@ -203,21 +212,16 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps URL.revokeObjectURL(url); }, []); - // Filtered file list - const displayFileIds = (() => { - if (filterTab === 'all') return fileIds; - if (filterTab === 'pending') return pendingFileIds; - if (filterTab === 'processing') return processingFileIds; - if (filterTab === 'processed') return processedFileIds; - return fileIds; - })(); + const goHome = useCallback(() => { + setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null }); + actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID); + }, [setCustomWorkbenchViewData, actions]); - // Home page: no specific folder selected - if (!folderId) { - return ; - } + if (!folderId) return ; - const FolderIcon = folder ? (iconMap[folder.icon as keyof typeof iconMap] || iconMap.SettingsIcon) : iconMap.SettingsIcon; + const FolderIcon = folder + ? (iconMap[folder.icon as keyof typeof iconMap] || iconMap.FolderIcon) + : iconMap.FolderIcon; if (!folder) { return ( @@ -227,233 +231,580 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps ); } + 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; + return ( - {/* Header */} + + {/* ── Header ── */} - - - - - - {folder.name} - {folder.description && ( - {folder.description} - )} - + + + + + + + {/* Icon with status dot */} + + + + + + + + + + + + {folder.name} + + {/* Pipeline summary */} + {ops.length > 0 && ( + + {ops.map((op, i) => ( + + {i > 0 && ( + + )} + + {humaniseOp(op.operation)} + + + ))} + + )} + {folder.description && !ops.length && ( + {folder.description} + )} + + + + {/* Stats + Add files */} + + + + {processedFileIds.length} + + + {t('smartFolders.workbench.processed', 'Processed')} + + + + + {fileIds.length} + + + {t('smartFolders.workbench.total', 'Total')} + + + + + - {/* Main 3-column layout */} - - {/* Column 1: Drop zone + pending */} - - - - {t('smartFolders.workbench.dropFiles', 'Drop PDFs here')} - - + {/* ── Body ── */} + - {/* Drop zone */} - { e.preventDefault(); setIsDragOver(true); }} - onDragLeave={() => setIsDragOver(false)} - onDrop={handleDrop} - style={{ - border: `2px dashed ${isDragOver ? folder.accentColor : 'var(--border-subtle)'}`, - borderRadius: 'var(--mantine-radius-md)', - margin: '0.75rem', - padding: '1.5rem 1rem', - display: 'flex', - flexDirection: 'column', - alignItems: 'center', - gap: '0.5rem', - backgroundColor: isDragOver ? `${folder.accentColor}11` : 'transparent', - transition: 'all 0.15s', - cursor: 'pointer', - }} - onClick={() => document.getElementById(`folder-file-input-${folderId}`)?.click()} - > - - - {t('smartFolders.workbench.dropOrClick', 'Drop PDFs or click to browse')} - - - - - {/* File list with tabs */} - - setFilterTab((v as FilterTab) || 'all')} - style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }} + {/* Left: Activity */} + { e.preventDefault(); setIsDragOver(true); }} + onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node)) setIsDragOver(false); }} + onDrop={handleDrop} + > + {/* Storage cards */} + + - - {t('smartFolders.workbench.all', 'All')} ({fileIds.length}) - {t('smartFolders.workbench.pending', 'Pending')} ({pendingFileIds.length}) - {t('smartFolders.workbench.processing', 'Processing')} ({processingFileIds.length}) - {t('smartFolders.workbench.processed', 'Processed')} ({processedFileIds.length}) + {/* Originals */} + + + + + {t('smartFolders.workbench.originals', 'Originals')} + + + {inputFiles.length} + + {t('smartFolders.workbench.storedInBrowser', 'IndexedDB')} + + + + {/* Processed versions */} + + + + + {t('smartFolders.workbench.versions', 'Versions')} + + + {outputFiles.length} + + {t('smartFolders.workbench.processedVersions', 'IndexedDB')} + + + + + {/* Version history note */} + + + + {t( + 'smartFolders.workbench.versionNote', + 'Originals stay untouched — processed files stored as separate versions in IndexedDB' + )} + + + + + {/* Activity label */} + + + {isDragOver + ? t('smartFolders.workbench.dropToProcess', 'Drop to process') + : t('smartFolders.workbench.activity', 'Activity')} + + + + + + {stepProgresses.length === 0 && fileIds.length === 0 ? ( + + {t('smartFolders.workbench.noActivity', 'No activity yet — drop a PDF to start')} + + ) : ( + <> + {/* Live step progress */} + {[...stepProgresses].reverse().map((step, i) => ( + + + {step.status === 'running' && } + {step.status === 'completed' && ( + + )} + {step.status === 'error' && ( + + )} + + {`Step ${step.stepIndex + 1}: ${humaniseOp(step.operationName)}`} + + + {step.error && ( + {step.error} + )} + + ))} + + {/* File status list */} + {fileIds.map((fileId) => { + const meta = folderRecord?.files[fileId]; + const status = meta?.status ?? 'pending'; + return ( + + + + {status === 'processed' && ( + + )} + {status === 'processing' && } + {status === 'error' && ( + + )} + {status === 'pending' && ( + + )} + + {status === 'processed' + ? t('smartFolders.workbench.fileProcessed', 'File processed') + : status === 'processing' + ? t('smartFolders.workbench.fileProcessing', 'Processing…') + : status === 'error' + ? t('smartFolders.workbench.fileError', 'Error') + : t('smartFolders.workbench.filePending', 'Queued')} + + + + {meta?.processedAt + ? new Date(meta.processedAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : ''} + + + {meta?.errorMessage && ( + {meta.errorMessage} + )} + + ); + })} + + )} + + + + + {/* Right: Output files */} + + setFilterTab((v as FilterTab) || 'processed')} + style={{ display: 'flex', flexDirection: 'column', height: '100%' }} + > + + + + {t('smartFolders.workbench.processed', 'Processed')} + {processedFileIds.length > 0 && ( + + {processedFileIds.length} + + )} + + + {t('smartFolders.workbench.processing', 'Processing')} + {processingFileIds.length > 0 && ( + + {processingFileIds.length} + + )} + + + {t('smartFolders.workbench.pending', 'Pending')} + {pendingFileIds.length > 0 && ( + + {pendingFileIds.length} + + )} + - - - {displayFileIds.length === 0 ? ( - + + + + + {filterTab === 'processed' ? ( + recentRuns.length === 0 ? ( + + + + + + {t('smartFolders.workbench.noOutput', 'No processed files yet')} + + + {t('smartFolders.workbench.dropToStart', 'Drop files on the left to start')} + + + ) : ( + + {recentRuns.filter(r => r.status === 'processed').map((run, i) => { + const inputFile = inputFiles.find(f => f.fileId === run.inputFileId); + const outputFile = outputFiles.find(f => f.fileId === run.displayFileId); + const inputMeta = folderRecord?.files[run.inputFileId]; + return ( + + {/* Original row */} + + + + + {t('smartFolders.workbench.input', 'Input')} + + + {inputFile?.name ?? inputMeta?.name ?? run.inputFileId} + + + {inputFile && ( + handleDownload(inputFile.blob, inputFile.name)} + aria-label={t('smartFolders.workbench.downloadOriginal', 'Download original')} + > + + + )} + + {/* Processed row */} + + + + + {t('smartFolders.workbench.output', 'Output')} + + + {outputFile?.name ?? run.displayFileId} + + + {outputFile && ( + + )} + + + ); + })} + + ) + ) : ( + tabFileIds.length === 0 ? ( + {t('smartFolders.workbench.noFiles', 'No files')} ) : ( - displayFileIds.map((fileId) => { - const meta = folderRecord?.files[fileId]; - return ( - - - - {fileId.substring(0, 12)}… + + {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')} - - {meta?.status ?? 'pending'} - - - {meta?.errorMessage && ( - {meta.errorMessage} - )} - - ); - }) - )} - - - - - - - {/* Column 2: Processing log */} - - - - {t('smartFolders.workbench.processingLog', 'Processing log')} - - - - - {stepProgresses.length === 0 ? ( - - {t('smartFolders.workbench.noProcessing', 'No processing activity yet')} - - ) : ( - [...stepProgresses].reverse().map((step, i) => ( - - - {step.status === 'running' && } - {step.status === 'completed' && ( - - )} - {step.status === 'error' && ( - - )} - - {t('smartFolders.workbench.step', 'Step {{step}}: {{operation}}', { - step: step.stepIndex + 1, - operation: step.operationName, - })} - - - {step.error && {step.error}} - - )) - )} - - - - - {/* Column 3: Output files */} - - - - {t('smartFolders.workbench.output', 'Output files')} - - - - - {outputFiles.length === 0 ? ( - - {t('smartFolders.workbench.noOutput', 'No processed files yet')} - - ) : ( - outputFiles.map((file) => ( - - - - {file.name} - - handleDownload(file.fileId, file.name)} - aria-label={t('smartFolders.workbench.download', 'Download')} - > - - - - - )) - )} - - + + ); + })} + + ) + )} + + + diff --git a/frontend/src/core/services/folderStorage.ts b/frontend/src/core/services/folderStorage.ts index 2ed57ad55e..770bd8256f 100644 --- a/frontend/src/core/services/folderStorage.ts +++ b/frontend/src/core/services/folderStorage.ts @@ -14,11 +14,20 @@ interface OutputFileRecord { storedAt: number; } +interface InputFileRecord { + fileId: string; + folderId: string; + name: string; + blob: Blob; + storedAt: number; +} + class FolderStorage { private dbName = 'stirling-pdf-folder-files'; - private dbVersion = 1; + private dbVersion = 2; private recordsStore = 'folderRecords'; private outputStore = 'folderOutputFiles'; + private inputStore = 'folderInputFiles'; private db: IDBDatabase | null = null; async init(): Promise { @@ -43,6 +52,10 @@ class FolderStorage { const outputStore = db.createObjectStore(this.outputStore, { keyPath: 'fileId' }); outputStore.createIndex('folderId', 'folderId', { unique: false }); } + if (!db.objectStoreNames.contains(this.inputStore)) { + const inputStore = db.createObjectStore(this.inputStore, { keyPath: 'fileId' }); + inputStore.createIndex('folderId', 'folderId', { unique: false }); + } }; }); } @@ -140,11 +153,54 @@ class FolderStorage { }); } + async storeInputFile(folderId: string, fileId: string, blob: Blob, name: string): Promise { + const db = await this.ensureDB(); + const record: InputFileRecord = { fileId, folderId, name, blob, storedAt: Date.now() }; + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.inputStore], 'readwrite'); + const store = transaction.objectStore(this.inputStore); + const request = store.put(record); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error('Failed to store input file')); + }); + } + + async getInputFile(fileId: string): Promise { + const db = await this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.inputStore], 'readonly'); + const store = transaction.objectStore(this.inputStore); + const request = store.get(fileId); + request.onsuccess = () => resolve(request.result || null); + request.onerror = () => reject(new Error('Failed to get input file')); + }); + } + + async getInputFilesByFolder(folderId: string): Promise { + const db = await this.ensureDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction([this.inputStore], 'readonly'); + const store = transaction.objectStore(this.inputStore); + const index = store.index('folderId'); + const request = index.getAll(folderId); + request.onsuccess = () => resolve(request.result || []); + request.onerror = () => reject(new Error('Failed to get input files by folder')); + }); + } + async clearFolder(folderId: string): Promise { const db = await this.ensureDB(); - // Delete output files for this folder + // Delete output and input files for this folder const outputFileIds = await this.getOutputFileIdsByFolder(folderId); await Promise.all(outputFileIds.map(id => this.deleteOutputFile(db, id))); + const inputFiles = await this.getInputFilesByFolder(folderId); + await Promise.all(inputFiles.map(f => new Promise((resolve, reject) => { + const transaction = db.transaction([this.inputStore], 'readwrite'); + const store = transaction.objectStore(this.inputStore); + const request = store.delete(f.fileId); + request.onsuccess = () => resolve(); + request.onerror = () => reject(new Error('Failed to delete input file')); + }))); // Delete folder record return new Promise((resolve, reject) => { const transaction = db.transaction([this.recordsStore], 'readwrite'); @@ -250,4 +306,4 @@ class FolderStorage { } export const folderStorage = new FolderStorage(); -export type { OutputFileRecord }; +export type { OutputFileRecord, InputFileRecord }; diff --git a/frontend/src/core/types/smartFolders.ts b/frontend/src/core/types/smartFolders.ts index 1e75482ad1..00261da9f5 100644 --- a/frontend/src/core/types/smartFolders.ts +++ b/frontend/src/core/types/smartFolders.ts @@ -22,6 +22,7 @@ export interface FolderFileMetadata { displayFileId?: string; // output file id inputFileId?: string; errorMessage?: string; + name?: string; // original filename } export interface FolderRecord {