openreader/src/components/doclist/window/FinderSidebar.tsx
Richard R 0448835892 style(ui): improve bottom padding for safe area compatibility
update bottom padding on banners, status bars, and player controls to use
env(safe-area-inset-bottom) for better support on devices with display
cutouts or home indicators. also add viewportFit: cover to layout for
full-viewport rendering on mobile browsers.
2026-05-30 15:35:02 -06:00

377 lines
12 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';
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;
interface SidebarRowProps {
active: boolean;
onClick: () => void;
icon: ReactNode;
label: string;
count?: number;
countClassName?: string;
trailing?: ReactNode;
isDropTarget?: boolean;
}
function SidebarRow({
active,
onClick,
icon,
label,
count,
countClassName,
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 bg-transparent text-foreground hover:border-accent 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 transition-transform duration-200 ease-out ${countClassName ?? ''}`}
>
{count}
</span>
)}
{trailing}
</button>
);
}
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"
>
<SidebarRow
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}
/>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="absolute right-1 top-1/2 -translate-y-1/2 h-5 w-5 inline-flex items-center justify-center rounded text-muted opacity-0 group-hover/folder:opacity-100 group-focus-within/folder:opacity-100 hover:text-accent hover:bg-offbase transition"
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>
</button>
</div>
);
}
function SectionHeader({
children,
isFirst,
rightSlot,
}: {
children: ReactNode;
isFirst?: boolean;
rightSlot?: ReactNode;
}) {
return (
<div
className={
'px-2 pb-1 text-[10px] uppercase tracking-[0.08em] text-muted font-semibold leading-none flex items-center justify-between ' +
(isFirst ? 'pt-1.5' : 'pt-3')
}
>
<span>{children}</span>
{rightSlot && <span className="inline-flex items-center leading-none shrink-0">{rightSlot}</span>}
</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 (
<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 flex flex-col"
>
<div className="min-h-0 flex-1 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>
)}
<SectionHeader isFirst={!!topSlot}>Library</SectionHeader>
<SidebarRow
active={filter === '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');
onRowAction?.();
}}
icon={<ClockIcon className="w-3.5 h-3.5" />}
label="Recently Opened"
/>
<SectionHeader>Kinds</SectionHeader>
<SidebarRow
active={filter === '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');
onRowAction?.();
}}
icon={<EPUBIcon className="w-3.5 h-3.5" />}
label="EPUB"
count={counts.epub}
/>
<SidebarRow
active={filter === 'html'}
onClick={() => {
onFilterChange('html');
onRowAction?.();
}}
icon={<FileIcon className="w-3.5 h-3.5" />}
label="Text"
count={counts.html}
/>
<SectionHeader
rightSlot={(
<Menu as="div" className="relative inline-flex items-center leading-none text-left shrink-0 normal-case tracking-normal font-normal">
<MenuButton
className="inline-flex items-center justify-center h-3.5 w-5 rounded-sm text-muted hover:text-accent transition-colors duration-200 ease-out focus:outline-none"
title="Folder actions"
aria-label="Folder actions"
>
<DotsHorizontalIcon className="w-4 h-2.5" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
<MenuItems
anchor="bottom start"
className="z-50 mt-2 min-w-[180px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1 normal-case tracking-normal font-normal"
>
<MenuItem>
{({ active }) => (
<button
type="button"
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" />
New Folder
</button>
)}
</MenuItem>
<MenuItem disabled={folders.length === 0}>
{({ active, disabled }) => (
<button
type="button"
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`}
>
<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
</SectionHeader>
{folders.length === 0 ? (
<p className="px-2 py-1 text-[11px] text-muted">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}
/>
))
)}
</div>
</div>
{bottomSlot && (
<div
className="shrink-0 border-t border-offbase 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-offbase active:bg-accent transition-colors duration-200 ease-out"
/>
</aside>
);
}