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.
This commit is contained in:
parent
13e0647b47
commit
495c197c4c
5 changed files with 81 additions and 16 deletions
|
|
@ -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<Record<string, number>>({});
|
||||
|
||||
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<Folder[]>(() => {
|
||||
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<string>();
|
||||
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(
|
||||
() => ({
|
||||
|
|
|
|||
|
|
@ -34,17 +34,23 @@ const TILE_WIDTH_PX: Record<IconSize, number> = {
|
|||
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({
|
|||
>
|
||||
<div
|
||||
className="grid p-2"
|
||||
style={gridStyle(iconSize)}
|
||||
style={gridStyle(iconSize, folder.documents.length)}
|
||||
>
|
||||
{folder.documents.map((doc) => (
|
||||
<DocumentTile
|
||||
|
|
@ -186,7 +192,7 @@ export function IconsView({
|
|||
>
|
||||
<div
|
||||
className="grid"
|
||||
style={gridStyle(iconSize)}
|
||||
style={gridStyle(iconSize, unfolderedDocs.length)}
|
||||
>
|
||||
{folders.map((folder) => (
|
||||
<FolderGridRow
|
||||
|
|
|
|||
|
|
@ -179,7 +179,7 @@ export function FinderSidebar({
|
|||
active={filter === 'recents'}
|
||||
onClick={() => onFilterChange('recents')}
|
||||
icon={<ClockIcon className="w-3.5 h-3.5" />}
|
||||
label="Recents"
|
||||
label="Recently Opened"
|
||||
/>
|
||||
|
||||
<SectionLabel>Kinds</SectionLabel>
|
||||
|
|
|
|||
|
|
@ -825,6 +825,29 @@ 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 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<AppConfigRow | null> {
|
||||
return withDB(async () => {
|
||||
const row = await db[APP_CONFIG_TABLE].get('singleton');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue