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.
This commit is contained in:
Richard R 2026-05-28 22:27:15 -06:00
parent 2f1eafeb79
commit dca1a9b35d
12 changed files with 76 additions and 57 deletions

View file

@ -76,6 +76,7 @@ export function UserMenu({
onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`}
title="Disconnect account"
aria-label="Disconnect account"
>
<UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate flex-1">{session.user.email || 'Account'}</span>

View file

@ -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<string, number>, Record<string, number>>(
@ -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<string, DocumentListDocument>();
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<string, string>();
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 && (
<div className="px-3 pt-3 shrink-0 bg-background">

View file

@ -2,10 +2,14 @@ import type { DocumentListDocument } from '@/types/documents';
export const DND_DOCUMENT = 'openreader/document' as const;
export type DocumentIdentity = Pick<DocumentListDocument, 'id' | 'type'>;
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;

View file

@ -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`;
}

View file

@ -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) => ({

View file

@ -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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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') {

View file

@ -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<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
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]);

View file

@ -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'}`}
<span className="mx-1.5 text-muted"></span>
{formatSize(totalSize)}
{formatDocumentSize(totalSize)}
</span>
</div>
);

View file

@ -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 */}
<Transition show={isNarrow && sidebarOpen} as={Fragment}>
<Dialog
onClose={() => onSidebarOpenChange(false)}
onClose={() => undefined}
className="relative z-40 md:hidden"
>
<TransitionChild

View file

@ -2,7 +2,9 @@ import type { SVGProps } from 'react';
type IconProps = SVGProps<SVGSVGElement>;
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) => (
<svg {...baseSvg(props)}>

View file

@ -115,9 +115,19 @@ export function DocumentUploader({ className = '', variant = 'default', children
? 'Accepts PDF, EPUB, TXT, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'}
</p>
{error && (
<p className="mt-3 text-sm text-red-500">
Upload failed: {error} try again.
</p>
)}
</div>
</div>
)}
{!isDragActive && error && (
<div className="absolute inset-x-4 bottom-4 z-40 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-center text-sm text-red-500 pointer-events-none">
Upload failed: {error} try again.
</div>
)}
</div>
);
}

View file

@ -827,22 +827,24 @@ export async function clearHtmlDocuments(): Promise<void> {
export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> {
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<string, number> = {};
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;
});