From 495c197c4cbc088cb6c5071f4d4c981ce494283b Mon Sep 17 00:00:00 2001 From: Richard R Date: Wed, 27 May 2026 23:13:53 -0600 Subject: [PATCH] feat(doclist): add recently opened sorting and metadata to documents list Introduce recentlyOpenedAt field to document types and fetch per-document recently opened timestamps from local cache. Update the "Recently Opened" sidebar filter to sort by this value and show only documents with recent activity. Adjust grid layout logic for icons view to better handle small numbers of documents. Update sidebar label for clarity. --- src/components/doclist/DocumentList.tsx | 55 +++++++++++++++---- src/components/doclist/views/IconsView.tsx | 16 ++++-- .../doclist/window/FinderSidebar.tsx | 2 +- src/lib/client/dexie.ts | 23 ++++++++ src/types/documents.ts | 1 + 5 files changed, 81 insertions(+), 16 deletions(-) diff --git a/src/components/doclist/DocumentList.tsx b/src/components/doclist/DocumentList.tsx index 344ccaa..8d071ee 100644 --- a/src/components/doclist/DocumentList.tsx +++ b/src/components/doclist/DocumentList.tsx @@ -12,7 +12,11 @@ import type { SortDirection, ViewMode, } from '@/types/documents'; -import { getDocumentListState, saveDocumentListState } from '@/lib/client/dexie'; +import { + getDocumentListState, + getDocumentRecentlyOpenedMap, + saveDocumentListState, +} from '@/lib/client/dexie'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { CreateFolderDialog } from '@/components/doclist/CreateFolderDialog'; import { DocumentListSkeleton } from '@/components/doclist/DocumentListSkeleton'; @@ -126,6 +130,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { const [sidebarOpen, setSidebarOpen] = useState(true); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [query, setQuery] = useState(''); + const [recentlyOpenedById, setRecentlyOpenedById] = useState>({}); const [isInitialized, setIsInitialized] = useState(false); @@ -214,7 +219,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { }, [isNarrow]); // Build the union document list. - const allDocuments: DocumentListDocument[] = useMemo( + const rawDocuments: DocumentListDocument[] = useMemo( () => [ ...pdfDocs.map((d) => ({ ...d, type: 'pdf' as const })), ...epubDocs.map((d) => ({ ...d, type: 'epub' as const })), @@ -222,6 +227,35 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { ], [pdfDocs, epubDocs, htmlDocs], ); + const rawDocumentIdsKey = useMemo( + () => rawDocuments.map((d) => d.id).sort().join('|'), + [rawDocuments], + ); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const recentMap = await getDocumentRecentlyOpenedMap(); + if (cancelled) return; + setRecentlyOpenedById(recentMap); + } catch (err) { + console.warn('Failed to load recently opened cache metadata:', err); + } + })(); + return () => { + cancelled = true; + }; + }, [rawDocumentIdsKey]); + + const allDocuments: DocumentListDocument[] = useMemo( + () => + rawDocuments.map((doc) => ({ + ...doc, + recentlyOpenedAt: recentlyOpenedById[doc.id] ?? 0, + })), + [rawDocuments, recentlyOpenedById], + ); // Reconcile folders against server. useEffect(() => { @@ -244,7 +278,8 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { else if (sidebarFilter === 'html') docs = docs.filter((d) => d.type === 'html'); else if (sidebarFilter === 'recents') { docs = [...docs] - .sort((a, b) => b.lastModified - a.lastModified) + .filter((d) => (d.recentlyOpenedAt ?? 0) > 0) + .sort((a, b) => (b.recentlyOpenedAt ?? 0) - (a.recentlyOpenedAt ?? 0)) .slice(0, 20); } else if (sidebarFilter.startsWith('folder:')) { const fid = sidebarFilter.slice('folder:'.length); @@ -256,14 +291,13 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { }, [allDocuments, sidebarFilter, query, folders]); // Apply sort. - const sortedVisible = useMemo( - () => sortDocs(visibleAll, sortBy, sortDirection), - [visibleAll, sortBy, sortDirection], - ); + const sortedVisible = useMemo(() => { + if (sidebarFilter === 'recents') return visibleAll; + return sortDocs(visibleAll, sortBy, sortDirection); + }, [visibleAll, sidebarFilter, sortBy, sortDirection]); // Split into folders + unfoldered for the current filter context. - const showAllScope = - sidebarFilter === 'all' || sidebarFilter === 'recents'; + const showAllScope = sidebarFilter === 'all'; const visibleFolders = useMemo(() => { if (!showAllScope) return []; // Within a kind-filter (pdf/epub/html), still show folders that contain matches. @@ -280,10 +314,11 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) { }, [folders, showAllScope, query, sortBy, sortDirection]); const unfolderedDocs = useMemo(() => { + if (sidebarFilter === 'recents') return sortedVisible; const inFolder = new Set(); folders.forEach((f) => f.documents.forEach((d) => inFolder.add(d.id))); return sortedVisible.filter((d) => !inFolder.has(d.id)); - }, [folders, sortedVisible]); + }, [folders, sidebarFilter, sortedVisible]); const counts = useMemo( () => ({ diff --git a/src/components/doclist/views/IconsView.tsx b/src/components/doclist/views/IconsView.tsx index 5dfb602..0a09c37 100644 --- a/src/components/doclist/views/IconsView.tsx +++ b/src/components/doclist/views/IconsView.tsx @@ -34,17 +34,23 @@ const TILE_WIDTH_PX: Record = { xl: 192, }; -function responsiveGridTemplate(iconSize: IconSize): string { +const SMALL_GRID_ITEM_COUNT = 3; + +function responsiveGridTemplate(iconSize: IconSize, itemCount: number): string { const width = TILE_WIDTH_PX[iconSize]; + if (itemCount <= SMALL_GRID_ITEM_COUNT) { + return `repeat(auto-fill, minmax(${width}px, ${width}px))`; + } return `repeat(auto-fit, minmax(${width}px, 1fr))`; } const gridGap = '12px'; -function gridStyle(iconSize: IconSize): React.CSSProperties { +function gridStyle(iconSize: IconSize, itemCount: number): React.CSSProperties { return { - gridTemplateColumns: responsiveGridTemplate(iconSize), + gridTemplateColumns: responsiveGridTemplate(iconSize, itemCount), gap: gridGap, + justifyContent: itemCount <= SMALL_GRID_ITEM_COUNT ? 'start' : undefined, }; } @@ -136,7 +142,7 @@ function FolderGridRow({ >
{folder.documents.map((doc) => (
{folders.map((folder) => ( onFilterChange('recents')} icon={} - label="Recents" + label="Recently Opened" /> Kinds diff --git a/src/lib/client/dexie.ts b/src/lib/client/dexie.ts index 3bb4d0a..cd1baf1 100644 --- a/src/lib/client/dexie.ts +++ b/src/lib/client/dexie.ts @@ -825,6 +825,29 @@ 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 value = toPositiveInt(ts, 0); + if (value <= 0) return; + if (!byId[id] || value > byId[id]) byId[id] = 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)); + + return byId; + }); +} + export async function getAppConfig(): Promise { return withDB(async () => { const row = await db[APP_CONFIG_TABLE].get('singleton'); diff --git a/src/types/documents.ts b/src/types/documents.ts index 5d2b61b..029a41a 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -5,6 +5,7 @@ export interface BaseDocument { name: string; size: number; lastModified: number; + recentlyOpenedAt?: number; type: DocumentType; parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; parsedJsonKey?: string | null;