From d120719abd218c27e1553c05479779b8f3ca1b99 Mon Sep 17 00:00:00 2001 From: Richard R Date: Fri, 29 May 2026 22:11:27 -0600 Subject: [PATCH] refactor(doclist): implement upload batch progress tracking in sidebar Add SidebarUploadLoader component to DocumentList for displaying real-time upload progress in the sidebar. Extend DocumentUploader to emit detailed batch state updates via onUploadBatchChange, enabling granular feedback on current file, phase, and completion status. Improves user awareness of document upload and conversion processes. --- src/components/doclist/DocumentList.tsx | 117 +++++++++++++++++- src/components/documents/DocumentUploader.tsx | 85 ++++++++++++- 2 files changed, 194 insertions(+), 8 deletions(-) diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 39d2c57..77dfd36 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -21,7 +21,7 @@ import { import { ConfirmDialog } from '@/components/ConfirmDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; -import { DocumentUploader } from '@/components/documents/DocumentUploader'; +import { DocumentUploader, type UploadBatchState } from '@/components/documents/DocumentUploader'; import { DocumentDndProvider } from './dnd/DocumentDndProvider'; import { DocumentSelectionProvider, @@ -118,6 +118,70 @@ interface DocumentListInnerProps { appActions?: ReactNode; } +function SidebarUploadLoader({ + totalFiles, + completedFiles, + phase, + currentFileName, +}: { + totalFiles: number; + completedFiles: number; + phase: 'uploading' | 'converting'; + currentFileName: string | null; +}) { + const progress = totalFiles > 0 ? Math.min(100, Math.round((completedFiles / totalFiles) * 100)) : 0; + const label = phase === 'converting' ? 'Converting' : 'Uploading'; + const radius = 7; + const stroke = 2; + const size = 18; + const normalizedRadius = radius - stroke / 2; + const circumference = 2 * Math.PI * normalizedRadius; + const dashOffset = circumference - (progress / 100) * circumference; + + return ( +
+
+
+ {label} + {completedFiles}/{totalFiles} +
+
+ {progress}% + + + + +
+
+ {currentFileName && ( +

+ {currentFileName} +

+ )} +
+ ); +} + function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const cachedState = cachedDocumentListState; const [sortBy, setSortBy] = useState(cachedState?.sortBy ?? DEFAULT_STATE.sortBy); @@ -135,6 +199,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [sidebarOpen, setSidebarOpen] = useState(!(cachedState?.sidebarCollapsed ?? false)); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); + const [activeUploadBatches, setActiveUploadBatches] = useState>({}); const [isInitialized, setIsInitialized] = useState(cachedState !== null); @@ -484,6 +549,29 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading; + const handleUploadBatchChange = useCallback((state: UploadBatchState) => { + setActiveUploadBatches((prev) => { + if (!state.isActive) { + if (!prev[state.uploaderId]) return prev; + const next = { ...prev }; + delete next[state.uploaderId]; + return next; + } + return { ...prev, [state.uploaderId]: state }; + }); + }, []); + + const sidebarUploadState = useMemo(() => { + const batches = Object.values(activeUploadBatches); + if (batches.length === 0) return null; + const totalFiles = batches.reduce((sum, batch) => sum + batch.totalFiles, 0); + const completedFiles = batches.reduce((sum, batch) => sum + batch.completedFiles, 0); + const convertingBatch = batches.find((batch) => batch.phase === 'converting'); + const phase: 'uploading' | 'converting' = convertingBatch ? 'converting' : 'uploading'; + const currentFileName = convertingBatch?.currentFileName ?? batches.find((batch) => batch.currentFileName)?.currentFileName ?? null; + return { totalFiles, completedFiles, phase, currentFileName }; + }, [activeUploadBatches]); + const fallbackViewMode: ViewMode = viewMode; const effectiveSidebarOpen = isNarrow ? mobileSidebarOpen : sidebarOpen; @@ -528,8 +616,20 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { onDropOnFolder={handleDropOnFolder} width={sidebarWidth} onWidthChange={setSidebarWidth} - topSlot={} - bottomSlot={appActions} + topSlot={} + bottomSlot={( +
+ {sidebarUploadState && ( + + )} + {appActions} +
+ )} onRowAction={() => { if (isNarrow) setMobileSidebarOpen(false); }} @@ -574,10 +674,17 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { ) : allDocuments.length === 0 ? (
- +
) : ( - + {fallbackViewMode === 'icons' && ( void; } -export function DocumentUploader({ className = '', variant = 'default', children }: DocumentUploaderProps) { +export interface UploadBatchState { + uploaderId: string; + isActive: boolean; + totalFiles: number; + completedFiles: number; + phase: 'uploading' | 'converting'; + currentFileName: string | null; +} + +export function DocumentUploader({ + className = '', + variant = 'default', + children, + onUploadBatchChange, +}: DocumentUploaderProps) { + const uploaderId = useId(); const enableDocx = useFeatureFlag('enableDocxConversion'); const { addPDFDocument: addPDF, @@ -25,30 +41,86 @@ export function DocumentUploader({ className = '', variant = 'default', children const [isConverting, setIsConverting] = useState(false); const [error, setError] = useState(null); + const emitBatchState = useCallback((state: Omit) => { + onUploadBatchChange?.({ uploaderId, ...state }); + }, [onUploadBatchChange, uploaderId]); + const onDrop = useCallback(async (acceptedFiles: File[]) => { if (!acceptedFiles || acceptedFiles.length === 0) return; + const totalFiles = acceptedFiles.length; + let completedFiles = 0; + setIsUploading(true); setError(null); + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: acceptedFiles[0]?.name ?? null, + }); try { for (const file of acceptedFiles) { if (file.type === 'application/pdf') { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addPDF(file); + completedFiles += 1; } else if (file.type === 'application/epub+zip') { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addEPUB(file); + completedFiles += 1; } else if (file.type === 'text/plain' || file.type === 'text/markdown' || file.name.endsWith('.md')) { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: file.name, + }); await addHTML(file); + completedFiles += 1; } else if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { // Preserve prior UX: show "Converting DOCX..." state rather than generic uploading. setIsUploading(false); setIsConverting(true); + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'converting', + currentFileName: file.name, + }); // Convert+upload directly on the server. Use sha(docx) as stable ID to avoid duplicates. await uploadDocxAsPdf(file); await refreshDocuments(); setIsConverting(false); setIsUploading(true); + completedFiles += 1; + } else { + continue; } + + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: null, + }); } } catch (err) { setError('Failed to upload file. Please try again.'); @@ -56,8 +128,15 @@ export function DocumentUploader({ className = '', variant = 'default', children } finally { setIsUploading(false); setIsConverting(false); + emitBatchState({ + isActive: false, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: null, + }); } - }, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx]); + }, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx, emitBatchState]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop,