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,