openreader/src/components/doclist/window/FinderSidebar.tsx
Richard R 495c197c4c 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.
2026-05-27 23:13:53 -06:00

236 lines
6.8 KiB
TypeScript

'use client';
import { useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
interface FinderSidebarProps {
filter: SidebarFilter;
onFilterChange: (filter: SidebarFilter) => void;
folders: Folder[];
counts: { all: number; pdf: number; epub: number; html: number };
/** When dragging onto a folder row, move dropped docs into that folder. */
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
/** Width controls (desktop only). */
width: number;
onWidthChange: (px: number) => void;
topSlot?: ReactNode;
}
const MIN_WIDTH = 168;
const MAX_WIDTH = 320;
interface SidebarRowProps {
active: boolean;
onClick: () => void;
icon: ReactNode;
label: string;
count?: number;
trailing?: ReactNode;
isDropTarget?: boolean;
}
function SidebarRow({
active,
onClick,
icon,
label,
count,
trailing,
isDropTarget,
}: SidebarRowProps) {
return (
<button
type="button"
onClick={onClick}
className={
'group w-full flex items-center gap-2 px-2 py-1 rounded-md text-[12px] border transform transition-all duration-200 ease-out text-left hover:scale-[1.01] ' +
(active
? 'border-accent bg-offbase text-accent'
: 'border-transparent text-foreground hover:border-accent hover:bg-offbase hover:text-accent') +
(isDropTarget ? ' ring-1 ring-accent' : '')
}
>
<span
className={
'w-4 h-4 shrink-0 flex items-center justify-center transition-colors duration-200 ' +
(active ? 'text-accent' : 'text-muted group-hover:text-accent')
}
>
{icon}
</span>
<span className="truncate flex-1">{label}</span>
{typeof count === 'number' && count > 0 && (
<span className="text-[10px] text-muted tabular-nums">{count}</span>
)}
{trailing}
</button>
);
}
function FolderRow({
folder,
active,
onClick,
onDropOnFolder,
}: {
folder: Folder;
active: boolean;
onClick: () => void;
onDropOnFolder: (folderId: string, item: DocumentDragItem) => void;
}) {
const [{ isOver, canDrop }, dropRef] = useDrop<
DocumentDragItem,
void,
{ isOver: boolean; canDrop: boolean }
>(() => ({
accept: DND_DOCUMENT,
drop: (item) => {
onDropOnFolder(folder.id, item);
},
canDrop: (item) => {
// Don't accept if all items are already in this folder.
return item.docs.some((d) => d.folderId !== folder.id);
},
collect: (monitor) => ({
isOver: monitor.isOver({ shallow: true }),
canDrop: monitor.canDrop(),
}),
}), [folder.id, onDropOnFolder]);
const isDropTarget = isOver && canDrop;
return (
<div ref={dropRef as unknown as React.RefObject<HTMLDivElement>}>
<SidebarRow
active={active}
onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={folder.name}
count={folder.documents.length}
isDropTarget={isDropTarget}
/>
</div>
);
}
function SectionLabel({ children, isFirst }: { children: ReactNode; isFirst?: boolean }) {
return (
<p
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
{children}
</p>
);
}
export function FinderSidebar({
filter,
onFilterChange,
folders,
counts,
onDropOnFolder,
width,
onWidthChange,
topSlot,
}: FinderSidebarProps) {
const startRef = useRef<{ x: number; w: number } | null>(null);
const onResizeStart = (e: React.PointerEvent) => {
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
startRef.current = { x: e.clientX, w: width };
};
const onResizeMove = (e: React.PointerEvent) => {
if (!startRef.current) return;
const delta = e.clientX - startRef.current.x;
const next = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, startRef.current.w + delta));
onWidthChange(next);
};
const onResizeEnd = (e: React.PointerEvent) => {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
startRef.current = null;
};
return (
<aside
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] bg-base border-r border-offbase shrink-0 overflow-y-auto"
>
<div className="p-2 flex flex-col gap-0.5">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
<SectionLabel isFirst={!!topSlot}>Library</SectionLabel>
<SidebarRow
active={filter === 'all'}
onClick={() => onFilterChange('all')}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarRow
active={filter === 'recents'}
onClick={() => onFilterChange('recents')}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SectionLabel>Kinds</SectionLabel>
<SidebarRow
active={filter === 'pdf'}
onClick={() => onFilterChange('pdf')}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarRow
active={filter === 'epub'}
onClick={() => onFilterChange('epub')}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => onFilterChange('html')}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="HTML / Text"
count={counts.html}
/>
{folders.length > 0 && (
<>
<SectionLabel>Folders</SectionLabel>
{folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => onFilterChange(`folder:${folder.id}`)}
onDropOnFolder={onDropOnFolder}
/>
))}
</>
)}
</div>
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize sidebar"
onPointerDown={onResizeStart}
onPointerMove={onResizeMove}
onPointerUp={onResizeEnd}
onPointerCancel={onResizeEnd}
className="hidden md:block absolute top-0 right-0 h-full w-1 cursor-col-resize hover:bg-offbase active:bg-accent transition-colors duration-200 ease-out"
/>
</aside>
);
}