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; });