fix(doclist): mobile drawer close behavior and recents toolbar polish
This commit is contained in:
parent
98736bf6e3
commit
5a684214c8
5 changed files with 180 additions and 86 deletions
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue