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} onClick={handleDisconnectAccount}
className={`${rowClass} ${className}`} className={`${rowClass} ${className}`}
title="Disconnect account" title="Disconnect account"
aria-label="Disconnect account"
> >
<UserIcon className="h-3.5 w-3.5 text-muted" /> <UserIcon className="h-3.5 w-3.5 text-muted" />
<span className="truncate flex-1">{session.user.email || 'Account'}</span> <span className="truncate flex-1">{session.user.email || 'Account'}</span>

View file

@ -27,7 +27,7 @@ import {
DocumentSelectionProvider, DocumentSelectionProvider,
useDocumentSelection, useDocumentSelection,
} from './dnd/DocumentSelectionContext'; } from './dnd/DocumentSelectionContext';
import type { DocumentDragItem } from './dnd/dndTypes'; import { documentIdentityKey, type DocumentDragItem } from './dnd/dndTypes';
import { FinderWindow, useIsNarrow } from './window/FinderWindow'; import { FinderWindow, useIsNarrow } from './window/FinderWindow';
import { FinderToolbar } from './window/FinderToolbar'; import { FinderToolbar } from './window/FinderToolbar';
import { FinderSidebar } from './window/FinderSidebar'; import { FinderSidebar } from './window/FinderSidebar';
@ -244,7 +244,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[pdfDocs, epubDocs, htmlDocs], [pdfDocs, epubDocs, htmlDocs],
); );
const rawDocumentIdsKey = useMemo( const rawDocumentIdsKey = useMemo(
() => rawDocuments.map((d) => d.id).sort().join('|'), () => rawDocuments.map((d) => documentIdentityKey(d)).sort().join('|'),
[rawDocuments], [rawDocuments],
); );
const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>( const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>(
@ -264,14 +264,14 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => () =>
rawDocuments.map((doc) => ({ rawDocuments.map((doc) => ({
...doc, ...doc,
recentlyOpenedAt: recentlyOpenedById[doc.id] ?? 0, recentlyOpenedAt: recentlyOpenedById[documentIdentityKey(doc)] ?? 0,
})), })),
[rawDocuments, recentlyOpenedById], [rawDocuments, recentlyOpenedById],
); );
const allDocumentsById = useMemo(() => { const allDocumentsById = useMemo(() => {
const map = new Map<string, DocumentListDocument>(); 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; return map;
}, [allDocuments]); }, [allDocuments]);
@ -280,7 +280,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
folders.map((folder) => ({ folders.map((folder) => ({
...folder, ...folder,
documents: folder.documents documents: folder.documents
.map((d) => allDocumentsById.get(d.id)) .map((d) => allDocumentsById.get(documentIdentityKey(d)))
.filter((d): d is DocumentListDocument => Boolean(d)) .filter((d): d is DocumentListDocument => Boolean(d))
.map((d) => ({ ...d, folderId: folder.id })), .map((d) => ({ ...d, folderId: folder.id })),
})), })),
@ -299,7 +299,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const folderIdByDocId = useMemo(() => { const folderIdByDocId = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
for (const folder of foldersWithLiveDocs) { 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; return map;
}, [foldersWithLiveDocs]); }, [foldersWithLiveDocs]);
@ -308,7 +308,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => () =>
allDocuments.map((doc) => ({ allDocuments.map((doc) => ({
...doc, ...doc,
folderId: folderIdByDocId.get(doc.id), folderId: folderIdByDocId.get(documentIdentityKey(doc)),
})), })),
[allDocuments, folderIdByDocId], [allDocuments, folderIdByDocId],
); );
@ -330,7 +330,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const folder = foldersWithLiveDocs.find((f) => f.id === fid); const folder = foldersWithLiveDocs.find((f) => f.id === fid);
docs = folder docs = folder
? folder.documents ? folder.documents
.map((d) => allDocumentsById.get(d.id)) .map((d) => allDocumentsById.get(documentIdentityKey(d)))
.filter((d): d is DocumentListDocument => Boolean(d)) .filter((d): d is DocumentListDocument => Boolean(d))
.map((d) => ({ ...d, folderId: fid })) .map((d) => ({ ...d, folderId: fid }))
: []; : [];
@ -390,13 +390,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
return { return {
...f, ...f,
documents: f.documents.filter( 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 const newDocs = item.docs
.filter((d) => !existingIds.has(d.id)) .filter((d) => !existingIdentities.has(documentIdentityKey(d)))
.map((d) => ({ ...d, folderId })); .map((d) => ({ ...d, folderId }));
return { ...f, documents: [...f.documents, ...newDocs] }; return { ...f, documents: [...f.documents, ...newDocs] };
}), }),
@ -410,7 +410,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const handleMergeIntoFolder = useCallback( const handleMergeIntoFolder = useCallback(
(sources: DocumentListDocument[], target: DocumentListDocument) => { (sources: DocumentListDocument[], target: DocumentListDocument) => {
if (target.folderId) return; 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; if (filtered.length === 0) return;
setPendingMerge({ sources: filtered, target }); setPendingMerge({ sources: filtered, target });
setNewFolderName(''); setNewFolderName('');
@ -543,10 +544,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
/> />
} }
sidebarOpen={effectiveSidebarOpen} sidebarOpen={effectiveSidebarOpen}
onSidebarOpenChange={(open) => {
if (isNarrow) setMobileSidebarOpen(open);
else setSidebarOpen(open);
}}
> >
{!isLoading && showHint && allDocuments.length > 1 && ( {!isLoading && showHint && allDocuments.length > 1 && (
<div className="px-3 pt-3 shrink-0 bg-background"> <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 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 { export interface DocumentDragItem {
/** Doc ids being dragged together (may be a single id). */ /** Doc identities being dragged together (may be a single doc). */
ids: string[]; items: DocumentIdentity[];
/** Concrete doc records for the dragged ids — used for previews and folder hints. */ /** Concrete doc records for the dragged identities — used for previews and folder hints. */
docs: DocumentListDocument[]; docs: DocumentListDocument[];
/** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */ /** Folder id the drag originated from, if any (cross-folder vs unfoldered moves). */
fromFolderId?: string; fromFolderId?: string;

View file

@ -1,4 +1,7 @@
export function formatDocumentSize(bytes: number): string { export function formatDocumentSize(bytes: number): string {
if (bytes < 1024) {
return `${Math.max(0, Math.floor(bytes))} B`;
}
if (bytes >= 1024 * 1024 * 1024) { if (bytes >= 1024 * 1024 * 1024) {
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`; 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 { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
interface DocumentTileProps { interface DocumentTileProps {
doc: DocumentListDocument; doc: DocumentListDocument;
@ -95,14 +95,14 @@ export function DocumentTile({
// Reflect the actual drag in the selection so visuals match. // Reflect the actual drag in the selection so visuals match.
if (!isSelected) selection.replace([doc]); if (!isSelected) selection.replace([doc]);
return { return {
ids: dragging.map((d) => d.id), items: dragging.map(({ id, type }) => ({ id, type })),
docs: dragging, docs: dragging,
fromFolderId: doc.folderId, fromFolderId: doc.folderId,
}; };
}, },
collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }), collect: (monitor: DragSourceMonitor) => ({ isDragging: monitor.isDragging() }),
}; };
}, [doc, isSelected]); }, [doc, isSelected, selection]);
const [{ isOver, canDrop }, dropRef] = useDrop< const [{ isOver, canDrop }, dropRef] = useDrop<
DocumentDragItem, DocumentDragItem,
@ -113,7 +113,7 @@ export function DocumentTile({
canDrop: (item) => { canDrop: (item) => {
// Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself. // Only allow drop-to-merge on unfoldered docs, and don't drop a doc on itself.
if (isInFolder) return false; 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), drop: (item) => onMergeIntoFolder(item.docs, doc),
collect: (monitor) => ({ collect: (monitor) => ({

View file

@ -8,7 +8,7 @@ import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { DocumentPreview } from '@/components/doclist/DocumentPreview'; import { DocumentPreview } from '@/components/doclist/DocumentPreview';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
interface GalleryViewProps { interface GalleryViewProps {
documents: DocumentListDocument[]; documents: DocumentListDocument[];
@ -63,14 +63,18 @@ function GalleryThumb({
const sel = selection.getSelectedDocs(); const sel = selection.getSelectedDocs();
const dragging = isSelected && sel.length > 1 ? sel : [doc]; const dragging = isSelected && sel.length > 1 ? sel : [doc];
if (!isSelected) selection.replace([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() }), collect: (m) => ({ isDragging: m.isDragging() }),
}), [doc, isSelected]); }), [doc, isSelected, selection]);
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({ const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
accept: DND_DOCUMENT, 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), drop: (item) => onMergeIntoFolder(item.docs, doc),
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
}), [doc, isInFolder, onMergeIntoFolder]); }), [doc, isInFolder, onMergeIntoFolder]);
@ -155,6 +159,7 @@ export function GalleryView({
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (documents.length === 0) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if (target?.closest('input, textarea, [contenteditable]')) return; if (target?.closest('input, textarea, [contenteditable]')) return;
if (e.key === 'ArrowRight') { if (e.key === 'ArrowRight') {

View file

@ -12,7 +12,7 @@ import type {
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { formatDocumentSize } from '@/components/doclist/formatSize'; import { formatDocumentSize } from '@/components/doclist/formatSize';
import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes';
interface ListViewProps { interface ListViewProps {
documents: DocumentListDocument[]; documents: DocumentListDocument[];
@ -96,17 +96,17 @@ function DocRow({
const dragging = isSelected && selected.length > 1 ? selected : [doc]; const dragging = isSelected && selected.length > 1 ? selected : [doc];
if (!isSelected) selection.replace([doc]); if (!isSelected) selection.replace([doc]);
return { return {
ids: dragging.map((d) => d.id), items: dragging.map(({ id, type }) => ({ id, type })),
docs: dragging, docs: dragging,
fromFolderId: doc.folderId, fromFolderId: doc.folderId,
}; };
}, },
collect: (m) => ({ isDragging: m.isDragging() }), collect: (m) => ({ isDragging: m.isDragging() }),
}), [doc, isSelected]); }), [doc, isSelected, selection]);
const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({ const [{ isOver, canDrop }, dropRef] = useDrop<DocumentDragItem, void, { isOver: boolean; canDrop: boolean }>(() => ({
accept: DND_DOCUMENT, 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), drop: (item) => onMergeIntoFolder(item.docs, doc),
collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }), collect: (m) => ({ isOver: m.isOver({ shallow: true }), canDrop: m.canDrop() }),
}), [doc, isInFolder, onMergeIntoFolder]); }), [doc, isInFolder, onMergeIntoFolder]);

View file

@ -1,5 +1,7 @@
'use client'; 'use client';
import { formatDocumentSize } from '@/components/doclist/formatSize';
interface FinderStatusBarProps { interface FinderStatusBarProps {
itemCount: number; itemCount: number;
selectedCount: number; selectedCount: number;
@ -7,12 +9,6 @@ interface FinderStatusBarProps {
summary?: string; 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({ export function FinderStatusBar({
itemCount, itemCount,
selectedCount, selectedCount,
@ -31,7 +27,7 @@ export function FinderStatusBar({
? `${selectedCount} of ${itemCount} selected` ? `${selectedCount} of ${itemCount} selected`
: `${itemCount} item${itemCount === 1 ? '' : 's'}`} : `${itemCount} item${itemCount === 1 ? '' : 's'}`}
<span className="mx-1.5 text-muted"></span> <span className="mx-1.5 text-muted"></span>
{formatSize(totalSize)} {formatDocumentSize(totalSize)}
</span> </span>
</div> </div>
); );

View file

@ -10,7 +10,6 @@ interface FinderWindowProps {
children: ReactNode; children: ReactNode;
/** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */ /** Controlled sidebar open/closed state (drives mobile drawer + desktop collapse). */
sidebarOpen: boolean; sidebarOpen: boolean;
onSidebarOpenChange: (open: boolean) => void;
} }
const NARROW_QUERY = '(max-width: 767px)'; const NARROW_QUERY = '(max-width: 767px)';
@ -38,7 +37,6 @@ export function FinderWindow({
statusBar, statusBar,
children, children,
sidebarOpen, sidebarOpen,
onSidebarOpenChange,
}: FinderWindowProps) { }: FinderWindowProps) {
const isNarrow = useIsNarrow(); const isNarrow = useIsNarrow();
@ -61,7 +59,7 @@ export function FinderWindow({
{/* Mobile drawer */} {/* Mobile drawer */}
<Transition show={isNarrow && sidebarOpen} as={Fragment}> <Transition show={isNarrow && sidebarOpen} as={Fragment}>
<Dialog <Dialog
onClose={() => onSidebarOpenChange(false)} onClose={() => undefined}
className="relative z-40 md:hidden" className="relative z-40 md:hidden"
> >
<TransitionChild <TransitionChild

View file

@ -2,7 +2,9 @@ import type { SVGProps } from 'react';
type IconProps = SVGProps<SVGSVGElement>; 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', xmlns: 'http://www.w3.org/2000/svg',
viewBox: '0 0 24 24', viewBox: '0 0 24 24',
fill: 'none', fill: 'none',
@ -10,10 +12,11 @@ const baseSvg = (props: IconProps) => ({
strokeWidth: 1.6, strokeWidth: 1.6,
strokeLinecap: 'round' as const, strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const, strokeLinejoin: 'round' as const,
width: props.width ?? '1em', width,
height: props.height ?? '1em', height,
...props, ...rest,
}); };
};
export const SearchIcon = (props: IconProps) => ( export const SearchIcon = (props: IconProps) => (
<svg {...baseSvg(props)}> <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, MD, or DOCX'
: 'Accepts PDF, EPUB, TXT, or MD'} : 'Accepts PDF, EPUB, TXT, or MD'}
</p> </p>
{error && (
<p className="mt-3 text-sm text-red-500">
Upload failed: {error} try again.
</p>
)}
</div> </div>
</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> </div>
); );
} }

View file

@ -827,22 +827,24 @@ export async function clearHtmlDocuments(): Promise<void> {
export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> { export async function getDocumentRecentlyOpenedMap(): Promise<Record<string, number>> {
return withDB(async () => { 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 byId: Record<string, number> = {};
const write = (id: string, ts: unknown) => { const write = (identity: string, ts: unknown) => {
const value = toPositiveInt(ts, 0); const value = toPositiveInt(ts, 0);
if (value <= 0) return; 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)); await Promise.all([
epubRows.forEach((row) => write(row.id, row.cacheAccessedAt)); db[PDF_TABLE]
htmlRows.forEach((row) => write(row.id, row.cacheAccessedAt)); .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; return byId;
}); });