diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index b854554..dfe1a0e 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -228,13 +228,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const { pdfDocs, - removePDFDocument, isPDFLoading, epubDocs, - removeEPUBDocument, isEPUBLoading, htmlDocs, - removeHTMLDocument, + deleteDocument, isHTMLLoading, } = useDocuments(); @@ -436,9 +434,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const handleDelete = useCallback(async () => { if (!documentToDelete) return; try { - if (documentToDelete.type === 'pdf') await removePDFDocument(documentToDelete.id); - else if (documentToDelete.type === 'epub') await removeEPUBDocument(documentToDelete.id); - else if (documentToDelete.type === 'html') await removeHTMLDocument(documentToDelete.id); + await deleteDocument(documentToDelete.id); setFolders((prev) => prev.map((f) => ({ ...f, @@ -451,7 +447,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { } catch (err) { console.error('Failed to remove document:', err); } - }, [documentToDelete, removePDFDocument, removeEPUBDocument, removeHTMLDocument]); + }, [deleteDocument, documentToDelete]); const handleDeleteDoc = useCallback((doc: DocumentListDocument) => { setDocumentToDelete({ id: doc.id, name: doc.name, type: doc.type }); diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index 4f1a07c..54d8859 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -33,9 +33,7 @@ export function DocumentUploader({ const uploaderId = useId(); const enableDocx = useFeatureFlag('enableDocxConversion'); const { - addPDFDocument: addPDF, - addEPUBDocument: addEPUB, - addHTMLDocument: addHTML, + uploadDocuments, refreshDocuments, } = useDocuments(); const [isUploading, setIsUploading] = useState(false); @@ -63,57 +61,60 @@ export function DocumentUploader({ }); try { + const docxFiles: File[] = []; + const regularFiles: File[] = []; + 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 { + if (enableDocx && file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') { + docxFiles.push(file); continue; } + if ( + file.type === 'application/pdf' + || file.type === 'application/epub+zip' + || file.type === 'text/plain' + || file.type === 'text/markdown' + || file.name.endsWith('.md') + ) { + regularFiles.push(file); + } + } + + if (regularFiles.length > 0) { + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: regularFiles[0]?.name ?? null, + }); + await uploadDocuments(regularFiles); + completedFiles += regularFiles.length; + emitBatchState({ + isActive: true, + totalFiles, + completedFiles, + phase: 'uploading', + currentFileName: null, + }); + } + + for (const file of docxFiles) { + // 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, + }); + await uploadDocxAsPdf(file); + await refreshDocuments(); + setIsConverting(false); + setIsUploading(true); + completedFiles += 1; emitBatchState({ isActive: true, @@ -137,7 +138,7 @@ export function DocumentUploader({ currentFileName: null, }); } - }, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx, emitBatchState]); + }, [uploadDocuments, refreshDocuments, enableDocx, emitBatchState]); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, diff --git a/src/contexts/DocumentContext.tsx b/src/contexts/DocumentContext.tsx index f45d8b1..860fa91 100644 --- a/src/contexts/DocumentContext.tsx +++ b/src/contexts/DocumentContext.tsx @@ -3,32 +3,53 @@ import { createContext, useCallback, useContext, useEffect, useMemo, ReactNode } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import type { BaseDocument } from '@/types/documents'; -import { listDocuments, uploadDocuments, deleteDocuments } from '@/lib/client/api/documents'; -import { putCachedEpub, putCachedHtml, putCachedPdf, evictCachedEpub, evictCachedHtml, evictCachedPdf } from '@/lib/client/cache/documents'; +import { + deleteDocuments as deleteServerDocuments, + listDocuments, + uploadDocuments as uploadServerDocuments, +} from '@/lib/client/api/documents'; +import { cacheStoredDocumentFromBytes, evictCachedDocument } from '@/lib/client/cache/documents'; import { useAuthSession } from '@/hooks/useAuthSession'; interface DocumentContextType { pdfDocs: Array; - addPDFDocument: (file: File) => Promise; - removePDFDocument: (id: string) => Promise; isPDFLoading: boolean; epubDocs: Array; - addEPUBDocument: (file: File) => Promise; - removeEPUBDocument: (id: string) => Promise; isEPUBLoading: boolean; htmlDocs: Array; - addHTMLDocument: (file: File) => Promise; - removeHTMLDocument: (id: string) => Promise; isHTMLLoading: boolean; + uploadDocuments: (files: File[]) => Promise; + deleteDocument: (id: string) => Promise; refreshDocuments: () => Promise; } const DocumentContext = createContext(undefined); const DOCUMENTS_QUERY_KEY = 'documents'; +type SupportedDocument = BaseDocument & { type: 'pdf' | 'epub' | 'html' }; + +function mergeStoredDocuments( + previous: SupportedDocument[] | undefined, + uploaded: BaseDocument[], +): SupportedDocument[] { + const next = [...(previous ?? [])]; + + for (let index = uploaded.length - 1; index >= 0; index -= 1) { + const stored = uploaded[index]; + if (stored.type !== 'pdf' && stored.type !== 'epub' && stored.type !== 'html') { + continue; + } + const supportedStored = stored as SupportedDocument; + const withoutExisting = next.filter((document) => document.id !== supportedStored.id); + next.splice(0, next.length, supportedStored, ...withoutExisting); + } + + return next; +} + export function DocumentProvider({ children }: { children: ReactNode }) { const queryClient = useQueryClient(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); @@ -81,67 +102,43 @@ export function DocumentProvider({ children }: { children: ReactNode }) { return { pdfDocs, epubDocs, htmlDocs }; }, [docs]); - const cacheUploaded = useCallback(async (stored: BaseDocument, file: File) => { - try { - if (stored.type === 'pdf') { - await putCachedPdf(stored, await file.arrayBuffer()); - } else if (stored.type === 'epub') { - await putCachedEpub(stored, await file.arrayBuffer()); - } else if (stored.type === 'html') { - const buf = await file.arrayBuffer(); - const decoded = new TextDecoder().decode(new Uint8Array(buf)); - await putCachedHtml(stored, decoded); - } - } catch (err) { - // Cache failures should not block uploads. - console.warn('Failed to cache uploaded document:', stored.id, err); - } - }, []); + const uploadDocuments = useCallback(async (files: File[]): Promise => { + if (files.length === 0) return []; - const addDocument = useCallback(async (file: File): Promise => { - const [stored] = await uploadDocuments([file]); - if (!stored) throw new Error('Upload succeeded but returned no document'); - await cacheUploaded(stored, file); - const isSupported = stored.type === 'pdf' || stored.type === 'epub' || stored.type === 'html'; - if (!isSupported) return stored.id; - const supportedStored = stored as BaseDocument & { type: 'pdf' | 'epub' | 'html' }; - queryClient.setQueryData>(documentsQueryKey, (prev = []) => { - const next = prev.filter((d) => d.id !== supportedStored.id); - return [supportedStored, ...next]; - }); - return stored.id; - }, [cacheUploaded, queryClient, documentsQueryKey]); - - const addPDFDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); - const addEPUBDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); - const addHTMLDocument = useCallback(async (file: File) => addDocument(file), [addDocument]); - - const removeById = useCallback(async (id: string) => { - await deleteDocuments({ ids: [id] }); - await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]); - queryClient.setQueryData>(documentsQueryKey, (prev = []) => - prev.filter((d) => d.id !== id), + const stored = await uploadServerDocuments(files); + await Promise.allSettled( + stored.map(async (document, index) => { + const file = files[index]; + if (!file) return; + await cacheStoredDocumentFromBytes(document, await file.arrayBuffer()); + }), ); - }, [queryClient, documentsQueryKey]); - const removePDFDocument = useCallback(async (id: string) => removeById(id), [removeById]); - const removeEPUBDocument = useCallback(async (id: string) => removeById(id), [removeById]); - const removeHTMLDocument = useCallback(async (id: string) => removeById(id), [removeById]); + queryClient.setQueryData(documentsQueryKey, (previous) => + mergeStoredDocuments(previous, stored), + ); + + return stored; + }, [documentsQueryKey, queryClient]); + + const deleteDocument = useCallback(async (id: string) => { + await deleteServerDocuments({ ids: [id] }); + await evictCachedDocument(id); + queryClient.setQueryData(documentsQueryKey, (previous = []) => + previous.filter((document) => document.id !== id), + ); + }, [documentsQueryKey, queryClient]); return ( diff --git a/src/lib/client/cache/documents.ts b/src/lib/client/cache/documents.ts index b552c0a..ef3aa06 100644 --- a/src/lib/client/cache/documents.ts +++ b/src/lib/client/cache/documents.ts @@ -107,6 +107,10 @@ export async function evictCachedHtml(id: string): Promise { await removeHtmlDocument(id); } +export async function evictCachedDocument(id: string): Promise { + await Promise.allSettled([evictCachedPdf(id), evictCachedEpub(id), evictCachedHtml(id)]); +} + export async function clearDocumentCache(): Promise { const { clearPdfDocuments, clearEpubDocuments, clearHtmlDocuments } = await import('@/lib/client/dexie'); await Promise.all([clearPdfDocuments(), clearEpubDocuments(), clearHtmlDocuments()]);