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 */}
+ {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;
});