openreader/src/components/doclist/window/FinderSidebar.tsx
Richard R d4afa43abf refactor(ui): unify button usage in settings, admin, and doclist components
Replace native button elements with shared Button, ChoiceTile, and IconButton
components for consistent UI behavior and styling. Update classNames and
props to match new component APIs. Adjust FinderSidebar to use utility
function for conditional class merging.
2026-06-01 11:58:30 -06:00

310 lines
11 KiB
TypeScript

'use client';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment, useRef, type CSSProperties, type ReactNode } from 'react';
import { useDrop } from 'react-dnd';
import type { Folder, SidebarFilter } from '@/types/documents';
import { PDFIcon, EPUBIcon, FileIcon, DotsHorizontalIcon } from '@/components/icons/Icons';
import { FolderIcon, HomeIcon, ClockIcon, FolderPlusIcon } from './finderIcons';
import { DND_DOCUMENT, type DocumentDragItem } from '../dnd/dndTypes';
import { IconButton, MenuItemClass, Sidebar as SidebarShell, SidebarNav, SidebarNavGroup, SidebarNavItem, cn, menuPanelClass } from '@/components/ui';
interface FinderSidebarProps {
filter: SidebarFilter;
onFilterChange: (filter: SidebarFilter) => void;
folders: Folder[];
counts: { all: number; pdf: number; epub: number; html: number };
onDeleteFolder: (folderId: string) => void;
onNewFolder: () => void;
onClearFolders: () => void;
/** 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;
bottomSlot?: ReactNode;
/** Fired for explicit row/button actions (used to close mobile drawer). */
onRowAction?: () => void;
}
const MIN_WIDTH = 168;
const MAX_WIDTH = 320;
function FolderRow({
folder,
active,
onClick,
onDelete,
onDropOnFolder,
}: {
folder: Folder;
active: boolean;
onClick: () => void;
onDelete: () => 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>}
className="group/folder relative"
>
<SidebarNavItem
compact
active={active}
onClick={onClick}
icon={<FolderIcon className="w-3.5 h-3.5" />}
label={folder.name}
count={folder.documents.length}
countClassName="group-hover/folder:-translate-x-6 group-focus-within/folder:-translate-x-6"
isDropTarget={isDropTarget}
/>
<IconButton
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
size="xs"
className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100"
aria-label={`Delete ${folder.name}`}
title={`Delete ${folder.name}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</IconButton>
</div>
);
}
export function FinderSidebar({
filter,
onFilterChange,
folders,
counts,
onDeleteFolder,
onNewFolder,
onClearFolders,
onDropOnFolder,
width,
onWidthChange,
topSlot,
bottomSlot,
onRowAction,
}: 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 (
<SidebarShell
style={{ '--sidebar-width': `${width}px` } as CSSProperties}
className="relative h-full w-full md:[width:var(--sidebar-width)] rounded-none border-y-0 border-l-0 border-r border-line-soft shadow-none shrink-0 flex flex-col"
>
<div className="min-h-0 flex-1 overflow-y-auto">
<SidebarNav className="p-2">
{topSlot && (
<div className="mb-0.5 shrink-0" onClick={(e) => e.stopPropagation()}>
{topSlot}
</div>
)}
<SidebarNavGroup isFirst={!!topSlot}>Library</SidebarNavGroup>
<SidebarNavItem
compact
active={filter === 'all'}
onClick={() => {
onFilterChange('all');
onRowAction?.();
}}
icon={<HomeIcon className="w-3.5 h-3.5" />}
label="All Documents"
count={counts.all}
/>
<SidebarNavItem
compact
active={filter === 'recents'}
onClick={() => {
onFilterChange('recents');
onRowAction?.();
}}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SidebarNavGroup>Kinds</SidebarNavGroup>
<SidebarNavItem
compact
active={filter === 'pdf'}
onClick={() => {
onFilterChange('pdf');
onRowAction?.();
}}
icon={<PDFIcon className="w-3.5 h-3.5" />}
label="PDF"
count={counts.pdf}
/>
<SidebarNavItem
compact
active={filter === 'epub'}
onClick={() => {
onFilterChange('epub');
onRowAction?.();
}}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarNavItem
compact
active={filter === 'html'}
onClick={() => {
onFilterChange('html');
onRowAction?.();
}}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="Text"
count={counts.html}
/>
<SidebarNavGroup
action={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
as={IconButton}
size="xs"
className="h-3.5 w-5"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-standard duration-fast"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-standard duration-fast"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className={`${menuPanelClass} z-50 mt-2 min-w-[180px] focus:outline-none normal-case tracking-normal font-normal`}
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => {
onNewFolder();
onRowAction?.();
}}
className={MenuItemClass(active)}
>
<FolderPlusIcon className="h-4 w-4" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => {
onClearFolders();
onRowAction?.();
}}
disabled={disabled}
className={cn(MenuItemClass(active), disabled && 'cursor-not-allowed text-faint')}
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Remove All Folders
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
)}
>
Folders
</SidebarNavGroup>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-soft">No folders yet</p>
) : (
folders.map((folder) => (
<FolderRow
key={folder.id}
folder={folder}
active={filter === `folder:${folder.id}`}
onClick={() => {
onFilterChange(`folder:${folder.id}`);
onRowAction?.();
}}
onDelete={() => {
onDeleteFolder(folder.id);
onRowAction?.();
}}
onDropOnFolder={onDropOnFolder}
/>
))
)}
</SidebarNav>
</div>
{bottomSlot && (
<div
className="shrink-0 border-t border-line-soft px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2"
onClick={(e) => e.stopPropagation()}
>
{bottomSlot}
</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-accent-wash active:bg-accent transition-colors duration-base ease-standard"
/>
</SidebarShell>
);
}