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.
This commit is contained in:
parent
b295d2f38d
commit
d120719abd
2 changed files with 194 additions and 8 deletions
|
|
@ -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 (
|
||||
<div className="rounded-md border border-offbase bg-offbase/60 px-2 py-1.5">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0 flex items-center gap-1.5 text-[11px] leading-tight">
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted">{completedFiles}/{totalFiles}</span>
|
||||
</div>
|
||||
<div className="shrink-0 flex items-center gap-1 text-accent" aria-label={`Upload progress ${progress}%`}>
|
||||
<span className="text-[10px] tabular-nums text-muted">{progress}%</span>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className={phase === 'converting' ? 'animate-spin' : ''}>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={normalizedRadius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeOpacity="0.2"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={normalizedRadius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
style={{ transition: 'stroke-dashoffset 200ms ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
{currentFileName && (
|
||||
<p className="mt-0.5 truncate text-[10px] text-muted" title={currentFileName}>
|
||||
{currentFileName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
||||
const cachedState = cachedDocumentListState;
|
||||
const [sortBy, setSortBy] = useState<SortBy>(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<Record<string, UploadBatchState>>({});
|
||||
|
||||
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={<DocumentUploader variant="compact" />}
|
||||
bottomSlot={appActions}
|
||||
topSlot={<DocumentUploader variant="compact" onUploadBatchChange={handleUploadBatchChange} />}
|
||||
bottomSlot={(
|
||||
<div className="flex flex-col gap-2">
|
||||
{sidebarUploadState && (
|
||||
<SidebarUploadLoader
|
||||
totalFiles={sidebarUploadState.totalFiles}
|
||||
completedFiles={sidebarUploadState.completedFiles}
|
||||
phase={sidebarUploadState.phase}
|
||||
currentFileName={sidebarUploadState.currentFileName}
|
||||
/>
|
||||
)}
|
||||
{appActions}
|
||||
</div>
|
||||
)}
|
||||
onRowAction={() => {
|
||||
if (isNarrow) setMobileSidebarOpen(false);
|
||||
}}
|
||||
|
|
@ -574,10 +674,17 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
|
|||
</div>
|
||||
) : allDocuments.length === 0 ? (
|
||||
<div className="flex-1 min-h-0 flex items-center justify-center p-6">
|
||||
<DocumentUploader className="py-12 w-full max-w-2xl" />
|
||||
<DocumentUploader
|
||||
className="py-12 w-full max-w-2xl"
|
||||
onUploadBatchChange={handleUploadBatchChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<DocumentUploader variant="overlay" className="flex-1 min-h-0 flex flex-col">
|
||||
<DocumentUploader
|
||||
variant="overlay"
|
||||
className="flex-1 min-h-0 flex flex-col"
|
||||
onUploadBatchChange={handleUploadBatchChange}
|
||||
>
|
||||
{fallbackViewMode === 'icons' && (
|
||||
<IconsView
|
||||
documents={sortedVisible}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useCallback, type ReactNode } from 'react';
|
||||
import { useState, useCallback, useId, type ReactNode } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { UploadIcon } from '@/components/icons/Icons';
|
||||
import { useDocuments } from '@/contexts/DocumentContext';
|
||||
|
|
@ -11,9 +11,25 @@ interface DocumentUploaderProps {
|
|||
className?: string;
|
||||
variant?: 'default' | 'compact' | 'overlay';
|
||||
children?: ReactNode;
|
||||
onUploadBatchChange?: (state: UploadBatchState) => 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<string | null>(null);
|
||||
|
||||
const emitBatchState = useCallback((state: Omit<UploadBatchState, 'uploaderId'>) => {
|
||||
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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue