fix(doclist): mobile drawer close behavior and recents toolbar polish

This commit is contained in:
Richard R 2026-05-28 17:45:05 -06:00
parent 98736bf6e3
commit 5a684214c8
5 changed files with 180 additions and 86 deletions

View file

@ -1,6 +1,7 @@
'use client';
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react';
import { useLiveQuery } from 'dexie-react-hooks';
import { useDocuments } from '@/contexts/DocumentContext';
import type {
DocumentListDocument,
@ -134,7 +135,6 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
const [sidebarOpen, setSidebarOpen] = useState(!(cachedState?.sidebarCollapsed ?? false));
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
const [query, setQuery] = useState('');
const [recentlyOpenedById, setRecentlyOpenedById] = useState<Record<string, number>>({});
const [isInitialized, setIsInitialized] = useState(cachedState !== null);
@ -247,22 +247,18 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => rawDocuments.map((d) => d.id).sort().join('|'),
[rawDocuments],
);
useEffect(() => {
let cancelled = false;
(async () => {
const recentlyOpenedById = useLiveQuery<Record<string, number>, Record<string, number>>(
async () => {
try {
const recentMap = await getDocumentRecentlyOpenedMap();
if (cancelled) return;
setRecentlyOpenedById(recentMap);
return await getDocumentRecentlyOpenedMap();
} catch (err) {
console.warn('Failed to load recently opened cache metadata:', err);
return {};
}
})();
return () => {
cancelled = true;
};
}, [rawDocumentIdsKey]);
},
[rawDocumentIdsKey],
{},
);
const allDocuments: DocumentListDocument[] = useMemo(
() =>
@ -291,6 +287,15 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
[folders, allDocumentsById],
);
const folderNameById = useMemo(
() =>
foldersWithLiveDocs.reduce<Record<string, string>>((acc, folder) => {
acc[folder.id] = folder.name;
return acc;
}, {}),
[foldersWithLiveDocs],
);
const folderIdByDocId = useMemo(() => {
const map = new Map<string, string>();
for (const folder of foldersWithLiveDocs) {
@ -471,6 +476,10 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
() => allDocuments.reduce((acc, d) => acc + d.size, 0),
[allDocuments],
);
const visibleSelectedCount = useMemo(
() => sortedVisible.reduce((count, doc) => count + (selection.isSelected(doc) ? 1 : 0), 0),
[sortedVisible, selection],
);
const isLoading = isPDFLoading || isEPUBLoading || isHTMLLoading;
@ -499,6 +508,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
: setSidebarOpen((p) => !p)
}
isSidebarOpen={effectiveSidebarOpen}
showSortControls={sidebarFilter !== 'recents'}
leftSlot={brand}
/>
}
@ -519,12 +529,15 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
onWidthChange={setSidebarWidth}
topSlot={<DocumentUploader variant="compact" />}
bottomSlot={appActions}
onRowAction={() => {
if (isNarrow) setMobileSidebarOpen(false);
}}
/>
}
statusBar={
<FinderStatusBar
itemCount={allDocuments.length}
selectedCount={selection.selectionSize}
selectedCount={visibleSelectedCount}
totalSize={totalBytes}
summary={summary}
/>
@ -589,6 +602,7 @@ function DocumentListInner({ brand, appActions }: DocumentListInnerProps) {
{fallbackViewMode === 'gallery' && (
<GalleryView
documents={sortedVisible}
folderNameById={folderNameById}
onDeleteDoc={handleDeleteDoc}
onMergeIntoFolder={handleMergeIntoFolder}
/>

View file

@ -12,10 +12,30 @@ import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface GalleryViewProps {
documents: DocumentListDocument[];
folderNameById?: Record<string, string>;
onDeleteDoc: (doc: DocumentListDocument) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: DocumentListDocument) => void;
}
function formatDateTime(value: number | undefined): string {
if (!value || !Number.isFinite(value) || value <= 0) return 'Never';
return new Date(value).toLocaleString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
function formatParseStatus(status: DocumentListDocument['parseStatus']): string {
if (!status) return 'N/A';
if (status === 'pending') return 'Pending';
if (status === 'running') return 'Running';
if (status === 'ready') return 'Ready';
return 'Failed';
}
function KindIcon({ doc, className }: { doc: DocumentListDocument; className?: string }) {
if (doc.type === 'pdf') return <PDFIcon className={className ?? 'w-4 h-4 text-red-500'} />;
if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
@ -107,6 +127,7 @@ function GalleryThumb({
export function GalleryView({
documents,
folderNameById,
onDeleteDoc,
onMergeIntoFolder,
}: GalleryViewProps) {
@ -150,34 +171,60 @@ export function GalleryView({
<div className="flex-1 min-h-0 flex flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
{activeDoc ? (
<div className="flex flex-col items-center gap-3 max-w-[420px]">
<div className="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
<DocumentPreview doc={activeDoc} />
</div>
<div className="text-center">
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
{activeDoc.name}
</h2>
<p className="text-[11px] text-muted">
{activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)}
</p>
</div>
<div className="flex gap-2">
<Link
href={openHref || '/app'}
prefetch={false}
className="h-8 px-4 inline-flex items-center justify-center rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.01] transition-all duration-200 ease-out"
>
Open
</Link>
<button
type="button"
onClick={() => onDeleteDoc(activeDoc)}
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.01] transition-all duration-200 ease-out"
>
Delete
</button>
<div className="w-full max-w-[920px] flex flex-col md:flex-row items-center md:items-start justify-center gap-4 md:gap-6">
<div className="flex flex-col items-center gap-3 w-[260px] sm:w-[320px] shrink-0">
<div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
<DocumentPreview doc={activeDoc} />
</div>
<div className="text-center">
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
{activeDoc.name}
</h2>
<p className="text-[11px] text-muted">
{activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)}
</p>
</div>
<div className="flex gap-2">
<Link
href={openHref || '/app'}
prefetch={false}
className="h-8 px-4 inline-flex items-center justify-center rounded-md bg-accent text-background text-[12px] font-medium hover:bg-secondary-accent hover:scale-[1.01] transition-all duration-200 ease-out"
>
Open
</Link>
<button
type="button"
onClick={() => onDeleteDoc(activeDoc)}
className="h-8 px-3 rounded-md border border-offbase bg-base text-[12px] text-muted hover:text-accent hover:border-accent hover:bg-offbase hover:scale-[1.01] transition-all duration-200 ease-out"
>
Delete
</button>
</div>
</div>
<dl className="w-full max-w-[360px] md:max-w-[340px] grid grid-cols-2 gap-x-2 gap-y-1 rounded-md border border-offbase bg-base px-3 py-2 text-[11px] md:self-center">
<dt className="text-muted">Type</dt>
<dd className="text-foreground text-right uppercase tracking-wide">{activeDoc.type}</dd>
<dt className="text-muted">Size</dt>
<dd className="text-foreground text-right tabular-nums">{formatDocumentSize(activeDoc.size)}</dd>
<dt className="text-muted">Last opened</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.recentlyOpenedAt)}</dd>
<dt className="text-muted">Last modified</dt>
<dd className="text-foreground text-right">{formatDateTime(activeDoc.lastModified)}</dd>
{activeDoc.folderId && (
<>
<dt className="text-muted">Folder</dt>
<dd className="text-foreground text-right truncate" title={folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}>
{folderNameById?.[activeDoc.folderId] ?? activeDoc.folderId}
</dd>
</>
)}
{activeDoc.type === 'pdf' && (
<>
<dt className="text-muted">Parse status</dt>
<dd className="text-foreground text-right">{formatParseStatus(activeDoc.parseStatus)}</dd>
</>
)}
</dl>
</div>
) : (
<p className="text-[12px] text-muted">No documents to show</p>

View file

@ -23,6 +23,8 @@ interface FinderSidebarProps {
onWidthChange: (px: number) => void;
topSlot?: ReactNode;
bottomSlot?: ReactNode;
/** Fired for explicit row/button actions (used to close mobile drawer). */
onRowAction?: () => void;
}
const MIN_WIDTH = 168;
@ -182,6 +184,7 @@ export function FinderSidebar({
onWidthChange,
topSlot,
bottomSlot,
onRowAction,
}: FinderSidebarProps) {
const startRef = useRef<{ x: number; w: number } | null>(null);
@ -215,14 +218,20 @@ export function FinderSidebar({
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === 'all'}
onClick={() => onFilterChange('all')}
onClick={() => {
onFilterChange('all');
onRowAction?.();
}}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarRow
active={filter === 'recents'}
onClick={() => onFilterChange('recents')}
onClick={() => {
onFilterChange('recents');
onRowAction?.();
}}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
@ -230,21 +239,30 @@ export function FinderSidebar({
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === 'pdf'}
onClick={() => onFilterChange('pdf')}
onClick={() => {
onFilterChange('pdf');
onRowAction?.();
}}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarRow
active={filter === 'epub'}
onClick={() => onFilterChange('epub')}
onClick={() => {
onFilterChange('epub');
onRowAction?.();
}}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => onFilterChange('html')}
onClick={() => {
onFilterChange('html');
onRowAction?.();
}}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="Text"
count={counts.html}
@ -277,7 +295,10 @@ export function FinderSidebar({
{({ active }) => (
<button
type="button"
onClick={onNewFolder}
onClick={() => {
onNewFolder();
onRowAction?.();
}}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
<FolderPlusIcon className="h-4 w-4" />
@ -289,7 +310,10 @@ export function FinderSidebar({
{({ active, disabled }) => (
<button
type="button"
onClick={onClearFolders}
onClick={() => {
onClearFolders();
onRowAction?.();
}}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} group flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
@ -315,8 +339,14 @@ export function FinderSidebar({
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDelete={() => onDeleteFolder(folder.id)}
onClick={() => {
onFilterChange(`folder:${folder.id}`);
onRowAction?.();
}}
onDelete={() => {
onDeleteFolder(folder.id);
onRowAction?.();
}}
onDropOnFolder={onDropOnFolder}
/>
))

View file

@ -25,6 +25,7 @@ interface FinderToolbarProps {
onQueryChange: (q: string) => void;
onToggleSidebar: () => void;
isSidebarOpen: boolean;
showSortControls?: boolean;
/** App-level content rendered at the far left (brand/logo). */
leftSlot?: ReactNode;
/** App-level content rendered at the far right (settings, user menu). */
@ -80,6 +81,7 @@ export function FinderToolbar({
onQueryChange,
onToggleSidebar,
isSidebarOpen,
showSortControls = true,
leftSlot,
rightSlot,
}: FinderToolbarProps) {
@ -161,42 +163,44 @@ export function FinderToolbar({
})}
</div>
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
{directionLabel}
</button>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
{showSortControls && (
<div className="flex items-center gap-1 shrink-0">
<button
type="button"
onClick={onSortDirectionToggle}
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} whitespace-nowrap`}
title="Toggle sort direction"
>
<span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton>
<ListboxOptions
anchor="bottom end"
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) =>
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
active ? 'bg-offbase text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
>
{opt.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
{directionLabel}
</button>
<Listbox value={sortBy} onChange={onSortByChange}>
<ListboxButton
className={`${TOOLBAR_BTN} ${TOOLBAR_BTN_INACTIVE} gap-1 min-w-[90px] justify-between`}
>
<span>{currentSort.label}</span>
<ChevronUpDownIcon className="h-3 w-3 opacity-60" />
</ListboxButton>
<ListboxOptions
anchor="bottom end"
className="z-50 mt-1 rounded-md bg-background border border-offbase shadow-lg p-1 focus:outline-none"
>
{SORT_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt.value}
className={({ active, selected }) =>
`cursor-pointer select-none rounded-sm py-1.5 px-2.5 text-xs ${
active ? 'bg-offbase text-accent' : 'text-foreground'
} ${selected ? 'font-semibold' : ''}`
}
>
{opt.label}
</ListboxOption>
))}
</ListboxOptions>
</Listbox>
</div>
)}
<div className="flex-1 min-w-0" />

View file

@ -87,7 +87,6 @@ export function FinderWindow({
>
<DialogPanel
className="w-[80vw] max-w-[280px] h-full bg-base shadow-xl"
onClick={() => onSidebarOpenChange(false)}
>
{sidebar}
</DialogPanel>