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

View file

@ -12,10 +12,30 @@ import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface GalleryViewProps { interface GalleryViewProps {
documents: DocumentListDocument[]; documents: DocumentListDocument[];
folderNameById?: Record<string, string>;
onDeleteDoc: (doc: DocumentListDocument) => void; onDeleteDoc: (doc: DocumentListDocument) => void;
onMergeIntoFolder: (sources: DocumentListDocument[], target: 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 }) { 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 === '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'} />; if (doc.type === 'epub') return <EPUBIcon className={className ?? 'w-4 h-4 text-blue-500'} />;
@ -107,6 +127,7 @@ function GalleryThumb({
export function GalleryView({ export function GalleryView({
documents, documents,
folderNameById,
onDeleteDoc, onDeleteDoc,
onMergeIntoFolder, onMergeIntoFolder,
}: GalleryViewProps) { }: 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 flex-col">
<div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background"> <div className="flex-1 min-h-0 flex items-center justify-center p-6 bg-background">
{activeDoc ? ( {activeDoc ? (
<div className="flex flex-col items-center gap-3 max-w-[420px]"> <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="w-[260px] sm:w-[320px] aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg"> <div className="flex flex-col items-center gap-3 w-[260px] sm:w-[320px] shrink-0">
<DocumentPreview doc={activeDoc} /> <div className="w-full aspect-[3/4] rounded-lg overflow-hidden border border-offbase shadow-lg">
</div> <DocumentPreview doc={activeDoc} />
<div className="text-center"> </div>
<h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]"> <div className="text-center">
{activeDoc.name} <h2 className="text-[14px] font-semibold text-foreground truncate max-w-[320px]">
</h2> {activeDoc.name}
<p className="text-[11px] text-muted"> </h2>
{activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)} <p className="text-[11px] text-muted">
</p> {activeDoc.type.toUpperCase()} {formatDocumentSize(activeDoc.size)}
</div> </p>
<div className="flex gap-2"> </div>
<Link <div className="flex gap-2">
href={openHref || '/app'} <Link
prefetch={false} href={openHref || '/app'}
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" 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> Open
<button </Link>
type="button" <button
onClick={() => onDeleteDoc(activeDoc)} type="button"
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" 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> Delete
</button>
</div>
</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> </div>
) : ( ) : (
<p className="text-[12px] text-muted">No documents to show</p> <p className="text-[12px] text-muted">No documents to show</p>

View file

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

View file

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

View file

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