From dca1a9b35d931618635c65e61698fa946e45268a Mon Sep 17 00:00:00 2001 From: Richard R Date: Thu, 28 May 2026 22:27:15 -0600 Subject: [PATCH] feat(doclist): unify document identity handling and improve drag-and-drop robustness Introduce a document identity key combining type and id for consistent identification across document operations, including selection, drag-and-drop, and folder management. Refactor drag item structures to use identity objects instead of plain ids, preventing cross-type collisions and improving merge accuracy. Update Dexie recently opened map to use identity keys. Enhance document size formatting for small files and improve error feedback in the uploader. Clean up redundant props and standardize icon SVG handling. --- src/components/auth/UserMenu.tsx | 1 + src/components/doclist/DocumentList.tsx | 29 +++++++++---------- src/components/doclist/dnd/dndTypes.ts | 10 +++++-- src/components/doclist/formatSize.ts | 3 ++ src/components/doclist/views/DocumentTile.tsx | 8 ++--- src/components/doclist/views/GalleryView.tsx | 13 ++++++--- src/components/doclist/views/ListView.tsx | 8 ++--- .../doclist/window/FinderStatusBar.tsx | 10 ++----- .../doclist/window/FinderWindow.tsx | 4 +-- src/components/doclist/window/finderIcons.tsx | 13 +++++---- src/components/documents/DocumentUploader.tsx | 10 +++++++ src/lib/client/dexie.ts | 24 ++++++++------- 12 files changed, 76 insertions(+), 57 deletions(-) diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index 529dac3..bbb6f88 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -76,6 +76,7 @@ export function UserMenu({ onClick={handleDisconnectAccount} className={`${rowClass} ${className}`} title="Disconnect account" + aria-label="Disconnect account" > {session.user.email || 'Account'} diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 073ced9..08423d7 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -27,7 +27,7 @@ import { DocumentSelectionProvider, useDocumentSelection, } from './dnd/DocumentSelectionContext'; -import type { DocumentDragItem } from './dnd/dndTypes'; +import { documentIdentityKey, type DocumentDragItem } from './dnd/dndTypes'; import { FinderWindow, useIsNarrow } from './window/FinderWindow'; import { FinderToolbar } from './window/FinderToolbar'; import { FinderSidebar } from './window/FinderSidebar'; @@ -244,7 +244,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { [pdfDocs, epubDocs, htmlDocs], ); const rawDocumentIdsKey = useMemo( - () => rawDocuments.map((d) => d.id).sort().join('|'), + () => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'), [rawDocuments], ); const recentlyOpenedById = useLiveQuery, Record>( @@ -264,14 +264,14 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { () => rawDocuments.map((doc) => ({ ...doc, - recentlyOpenedAt: recentlyOpenedById[doc.id] ?? 0, + recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0, })), [rawDocuments, recentlyOpenedById], ); const allDocumentsById = useMemo(() => { const map = new Map(); - for (const doc of allDocuments) map.set(doc.id, doc); + for (const doc of allDocuments) map.set(documentIdentityKey(doc), doc); return map; }, [allDocuments]); @@ -280,7 +280,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { folders.map((folder) => ({ ...folder, documents: folder.documents - .map((d) => allDocumentsById.get(d.id)) + .map((d) => allDocumentsById.get(documentIdentityKey(d))) .filter((d): d is DocumentListDocument => Boolean(d)) .map((d) => ({ ...d, folderId: folder.id })), })), @@ -299,7 +299,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const folderIdByDocId = useMemo(() => { const map = new Map(); for (const folder of foldersWithLiveDocs) { - for (const doc of folder.documents) map.set(doc.id, folder.id); + for (const doc of folder.documents) map.set(documentIdentityKey(doc), folder.id); } return map; }, [foldersWithLiveDocs]); @@ -308,7 +308,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { () => allDocuments.map((doc) => ({ ...doc, - folderId: folderIdByDocId.get(doc.id), + folderId: folderIdByDocId.get(documentIdentityKey(doc)), })), [allDocuments, folderIdByDocId], ); @@ -330,7 +330,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const folder = foldersWithLiveDocs.find((f) => f.id === fid); docs = folder ? folder.documents - .map((d) => allDocumentsById.get(d.id)) + .map((d) => allDocumentsById.get(documentIdentityKey(d))) .filter((d): d is DocumentListDocument => Boolean(d)) .map((d) => ({ ...d, folderId: fid })) : []; @@ -390,13 +390,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { return { ...f, documents: f.documents.filter( - (d) => !item.ids.includes(d.id), + (d) => !item.items.some((it) => it.id === d.id && it.type === d.type), ), }; } - const existingIds = new Set(f.documents.map((d) => d.id)); + const existingIdentities = new Set(f.documents.map((d) => documentIdentityKey(d))); const newDocs = item.docs - .filter((d) => !existingIds.has(d.id)) + .filter((d) => !existingIdentities.has(documentIdentityKey(d))) .map((d) => ({ ...d, folderId })); return { ...f, documents: [...f.documents, ...newDocs] }; }), @@ -410,7 +410,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const handleMergeIntoFolder = useCallback( (sources: DocumentListDocument[], target: DocumentListDocument) => { if (target.folderId) return; - const filtered = sources.filter((s) => s.id !== target.id && !s.folderId); + const targetKey = documentIdentityKey(target); + const filtered = sources.filter((s) => documentIdentityKey(s) !== targetKey && !s.folderId); if (filtered.length === 0) return; setPendingMerge({ sources: filtered, target }); setNewFolderName(''); @@ -543,10 +544,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { /> } sidebarOpen={effectiveSidebarOpen} - onSidebarOpenChange={(open) => { - if (isNarrow) setMobileSidebarOpen(open); - else setSidebarOpen(open); - }} > {!isLoading && showHint && allDocuments.length > 1 && (
diff --git a/src/components/doclist/dnd/dndTypes.ts b/src/components/doclist/dnd/dndTypes.ts index 077690c..61cc625 100644 --- a/src/components/doclist/dnd/dndTypes.ts +++ b/src/components/doclist/dnd/dndTypes.ts @@ -2,10 +2,14 @@ import type { DocumentListDocument } from '@/types/documents'; export const DND_DOCUMENT = 'openreader/document' as const; +export type DocumentIdentity = Pick; + +export const documentIdentityKey = ({ id, type }: DocumentIdentity): string => `${type}|${id}`; + export interface DocumentDragItem { - /** Doc ids being dragged together (may be a single id). */ - ids: string[]; - /** Concrete doc records for the dragged ids — used for previews and folder hints. */ + /** Doc identities being dragged together (may be a single doc). */ + items: DocumentIdentity[]; + /** Concrete doc records for the dragged identities — used for previews and folder hints. */ docs: DocumentListDocument[]; /** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */ fromFolderId?: string; diff --git a/src/components/doclist/formatSize.ts b/src/components/doclist/formatSize.ts index b3886cf..2402e36 100644 --- a/src/components/doclist/formatSize.ts +++ b/src/components/doclist/formatSize.ts @@ -1,4 +1,7 @@ export function formatDocumentSize(bytes: number): string { + if (bytes < 1024) { + return `${Math.max(0, Math.floor(bytes))} B`; + } if (bytes >= 1024 * 1024 * 1024) { return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; } diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index 832612d..e43ddef 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -9,7 +9,7 @@ import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; -import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; interface DocumentTileProps { doc: DocumentListDocument; @@ -95,14 +95,14 @@ export function DocumentTile({ // Reflect the actual drag in the selection so visuals match. if (!isSelected) selection.replace([doc]); return { - ids: dragging.map((d) => d.id), + items: dragging.map(({ id, type }) => ({ id, type })), docs: dragging, fromFolderId: doc.folderId, }; }, collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }), }; - }, [doc, isSelected]); + }, [doc, isSelected, selection]); const [{ isOver, canDrop }, dropRef] = useDrop< DocumentDragItem, @@ -113,7 +113,7 @@ export function DocumentTile({ canDrop: (item) => { // Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself. if (isInFolder) return false; - return !item.ids.includes(doc.id); + return !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)); }, drop: (item) => onMergeIntoFolder(item.docs, doc), collect: (monitor) => ({ diff --git a/src/components/doclist/views/GalleryView.tsx b/src/components/doclist/views/GalleryView.tsx index e931c67..eb8fd7b 100644 --- a/src/components/doclist/views/GalleryView.tsx +++ b/src/components/doclist/views/GalleryView.tsx @@ -8,7 +8,7 @@ import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { formatDocumentSize } from '@/components/doclist/formatSize'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; -import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; interface GalleryViewProps { documents: DocumentListDocument[]; @@ -63,14 +63,18 @@ function GalleryThumb({ const sel = selection.getSelectedDocs(); const dragging = isSelected && sel.length > 1 ? sel : [doc]; if (!isSelected) selection.replace([doc]); - return { ids: dragging.map((d) => d.id), docs: dragging, fromFolderId: doc.folderId }; + return { + items: dragging.map(({ id, type }) => ({ id, type })), + docs: dragging, + fromFolderId: doc.folderId, + }; }, collect: (m) => ({ isDragging: m.isDragging() }), - }), [doc, isSelected]); + }), [doc, isSelected, selection]); const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ accept: DND_DOCUMENT, - canDrop: (item) => !isInFolder && !item.ids.includes(doc.id), + canDrop: (item) => !isInFolder && !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)), drop: (item) => onMergeIntoFolder(item.docs, doc), collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), }), [doc, isInFolder, onMergeIntoFolder]); @@ -155,6 +159,7 @@ export function GalleryView({ useEffect(() => { const onKey = (e: KeyboardEvent) => { + if (documents.length === 0) return; const target = e.target as HTMLElement; if (target?.closest('input, textarea, [contenteditable]')) return; if (e.key === 'ArrowRight') { diff --git a/src/components/doclist/views/ListView.tsx b/src/components/doclist/views/ListView.tsx index 3b08d2c..e73f3ab 100644 --- a/src/components/doclist/views/ListView.tsx +++ b/src/components/doclist/views/ListView.tsx @@ -12,7 +12,7 @@ import type { import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { formatDocumentSize } from '@/components/doclist/formatSize'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; -import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; +import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; interface ListViewProps { documents: DocumentListDocument[]; @@ -96,17 +96,17 @@ function DocRow({ const dragging = isSelected && selected.length > 1 ? selected : [doc]; if (!isSelected) selection.replace([doc]); return { - ids: dragging.map((d) => d.id), + items: dragging.map(({ id, type }) => ({ id, type })), docs: dragging, fromFolderId: doc.folderId, }; }, collect: (m) => ({ isDragging: m.isDragging() }), - }), [doc, isSelected]); + }), [doc, isSelected, selection]); const [{ isOver, canDrop }, dropRef] = useDrop(() => ({ accept: DND_DOCUMENT, - canDrop: (item) => !isInFolder && !item.ids.includes(doc.id), + canDrop: (item) => !isInFolder && !item.items.some((it) => documentIdentityKey(it) === documentIdentityKey(doc)), drop: (item) => onMergeIntoFolder(item.docs, doc), collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), }), [doc, isInFolder, onMergeIntoFolder]); diff --git a/src/components/doclist/window/FinderStatusBar.tsx b/src/components/doclist/window/FinderStatusBar.tsx index 62c351f..e8c74de 100644 --- a/src/components/doclist/window/FinderStatusBar.tsx +++ b/src/components/doclist/window/FinderStatusBar.tsx @@ -1,5 +1,7 @@ 'use client'; +import { formatDocumentSize } from '@/components/doclist/formatSize'; + interface FinderStatusBarProps { itemCount: number; selectedCount: number; @@ -7,12 +9,6 @@ interface FinderStatusBarProps { summary?: string; } -function formatSize(bytes: number): string { - const mb = bytes / 1024 / 1024; - if (mb >= 1024) return `${(mb / 1024).toFixed(2)} GB`; - return `${mb.toFixed(2)} MB`; -} - export function FinderStatusBar({ itemCount, selectedCount, @@ -31,7 +27,7 @@ export function FinderStatusBar({ ? `${selectedCount} of ${itemCount} selected` : `${itemCount} item${itemCount === 1 ? '' : 's'}`} - {formatSize(totalSize)} + {formatDocumentSize(totalSize)}
); diff --git a/src/components/doclist/window/FinderWindow.tsx b/src/components/doclist/window/FinderWindow.tsx index c7b8db2..5926bdb 100644 --- a/src/components/doclist/window/FinderWindow.tsx +++ b/src/components/doclist/window/FinderWindow.tsx @@ -10,7 +10,6 @@ interface FinderWindowProps { children: ReactNode; /** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */ sidebarOpen: boolean; - onSidebarOpenChange: (open: boolean) => void; } const NARROW_QUERY = '(max-width: 767px)'; @@ -38,7 +37,6 @@ export function FinderWindow({ statusBar, children, sidebarOpen, - onSidebarOpenChange, }: FinderWindowProps) { const isNarrow = useIsNarrow(); @@ -61,7 +59,7 @@ export function FinderWindow({ {/* Mobile drawer */} onSidebarOpenChange(false)} + onClose={() => undefined} className="relative z-40 md:hidden" > ; -const baseSvg = (props: IconProps) => ({ +const baseSvg = (props: IconProps) => { + const { width = '1em', height = '1em', ...rest } = props; + return { xmlns: 'http://www.w3.org/2000/svg', viewBox: '0 0 24 24', fill: 'none', @@ -10,10 +12,11 @@ const baseSvg = (props: IconProps) => ({ strokeWidth: 1.6, strokeLinecap: 'round' as const, strokeLinejoin: 'round' as const, - width: props.width ?? '1em', - height: props.height ?? '1em', - ...props, -}); + width, + height, + ...rest, + }; +}; export const SearchIcon = (props: IconProps) => ( diff --git a/src/components/documents/DocumentUploader.tsx b/src/components/documents/DocumentUploader.tsx index 4d2f636..12083f4 100644 --- a/src/components/documents/DocumentUploader.tsx +++ b/src/components/documents/DocumentUploader.tsx @@ -115,9 +115,19 @@ export function DocumentUploader({ className = '', variant = 'default', children ? 'Accepts PDF, EPUB, TXT, MD, or DOCX' : 'Accepts PDF, EPUB, TXT, or MD'}

+ {error && ( +

+ Upload failed: {error} — try again. +

+ )} )} + {!isDragActive && error && ( +
+ Upload failed: {error} — try again. +
+ )} ); } diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index cd1baf1..40e99f6 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -827,22 +827,24 @@ export async function clearHtmlDocuments(): Promise { export async function getDocumentRecentlyOpenedMap(): Promise> { return withDB(async () => { - const [pdfRows, epubRows, htmlRows] = await Promise.all([ - db[PDF_TABLE].toArray(), - db[EPUB_TABLE].toArray(), - db[HTML_TABLE].toArray(), - ]); - const byId: Record = {}; - const write = (id: string, ts: unknown) => { + const write = (identity: string, ts: unknown) => { const value = toPositiveInt(ts, 0); if (value <= 0) return; - if (!byId[id] || value > byId[id]) byId[id] = value; + if (!byId[identity] || value > byId[identity]) byId[identity] = value; }; - pdfRows.forEach((row) => write(row.id, row.cacheAccessedAt)); - epubRows.forEach((row) => write(row.id, row.cacheAccessedAt)); - htmlRows.forEach((row) => write(row.id, row.cacheAccessedAt)); + await Promise.all([ + db[PDF_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`pdf|${String(cursor.primaryKey)}`, cacheAccessedAt)), + db[EPUB_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`epub|${String(cursor.primaryKey)}`, cacheAccessedAt)), + db[HTML_TABLE] + .orderBy('cacheAccessedAt') + .eachKey((cacheAccessedAt, cursor) => write(`html|${String(cursor.primaryKey)}`, cacheAccessedAt)), + ]); return byId; });