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.
377 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|