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,